First-class function
일급함수(First-class function)이란 함수 자체가 다른 값처럼 취급될 수 있는 걸 말한다.
그렇기 때문에 다른 함수의 인자값으로 들어갈 수 있다.
추상문법에서는 다음과 같이 정의될 수 있겠다.
App이 함수를 부르는 쪽인데 기존의 App 함수와 달라진 부분은 ftn인자값의 형태가 Expr이라는거다.
즉, Expr을 상속하고 있는 Fun 또한 이 인자값이 될 수 있다는 것을 의미한다.
여기서 사용 예를 볼 수 있는데, 왼쪽의 콘크리트 문법을 보면 첫번째 줄은 함수를 인자로 넣고 10이 함수의 인자가 되는 것을 볼 수 있다.
근데 두번째 줄도 첫번째 줄과 시멘틱적으로 똑같다.
왜냐면 with로 인해 y가 fun이 되는데 y에 10을 넣는다는 것은 함수의 인자에 10을 넣는거나 마찬가지이기 때문이다.
우리의 언어 만들기 프로젝트는 벌써 절반 온 것 같다.
AE -> WAE -> F1WAE -> FWAE(현재)
많은 기능이 추가되었다.
간단한 연산에서, with를 사용한 대체, 뭔가 아쉬운 함수, 이제 완벽한 것 같은 함수
이제 바뀐 FWAE의 인터프리터를 알아보자
def interp(expr: Expr): Expr = expr match {
case Num(n) => expr
case Add(l, r) => numAdd(interp(l), interp(r))
case Sub(l, r) => numSub(interp(l), interp(r))
case With(i, v, e) => interp(subst(e, i, interp(v)))
case Id(s) => throw new Exception(s"Free identifier: $s")
case Fun(p, b) => expr
case App(ftn, arg) =>
val evaluatedFtn = interp(ftn)
evaluatedFtn match {
case Fun(param, body) => interp(subst(body, param, interp(arg)))
case _ => throw new SimpleException("Expected a function")
}
}
Fun 부분과 App 부분이 바뀐 것을 확인할 수 있다.
또 한가지 바뀐 부분이 있는데, 기존의 F1WAE의 함수 반환 타입은 Num이었지만, FWAE는 Expr로 반한활 수 있게 됐다.
App의 바뀐 것 중 중요한 부분은 evaluatedFtn을 통해 이게 Fun을 이용한 것이 맞는지 체크하는 부분이다.
함수가 Expr이 됐으니, 다른 Num도 App에 들어갈 수 있지 않은가? 그런 것을 걸러주는 역할을 한다.
일급 함수의 대표적인 예시를 보자
def numAdd(x: Expr, y: Expr): Expr = (x, y) match {
case (Num(x), Num(y)) => Num(x + y)
}
def numSub(x: Expr, y: Expr): Expr = (x, y) match {
case (Num(x), Num(y)) => Num(x - y)
}
이 둘은 값을 받고 값을 반환하는데 뭔가 비효율적으로 보이지 않나?
겨우 더하기 빼기만 바뀌는건데 이렇게 두 개로 나누어 작성해야 한다니..
이런 중복을 없애기 위해서 다음과 같이 바꿀 수 있다.
def numOperator(op: (Int, Int) => Int): (Expr, Expr) => Expr = {
case (Num(x), Num(y)) => Num(op(x, y))
case _ => throw new SimpleException("Expected numerical expressions")
}
여기서 op는 두 개의 Int를 받아서 어떤 연산을 수행하는 함수다.
val numAdd: (Expr, Expr) => Expr = numOperator(_ + _) // 익명 함수로 + 연산
val numSub: (Expr, Expr) => Expr = numOperator(_ - _) // 익명 함수로 - 연산
여기서 _ + _와 _ - _는 익명 함수로, (x: Int, y: Int) => x + y 같은 의미다.
이렇게 하면 더 깔끔하고 유지보수가 쉽다.
이제 대체 쪽 정의도 바꿔야 한다.
def subst(expr: Expr, idtf: Id, value: Expr): Expr = expr match {
case Id(s) => if (s == idtf) value else expr
case App(ftn, arg) => App(subst(ftn, idtf, value), subst(arg, idtf, value))
case Fun(id, body) => if (id == idtf) expr else Fun(id, subst(body, idtf, value))
}
App(Id("f"), Id("x"))에서 x를 대체하면 함수는 그대로지만, 인자 x가 Num(3)으로 바뀌는 식으로 진행된다.
case Fun(id, body)에서 중요한 건 만약 함수의 매개변수 id가 우리가 대체하려는 식별자 idtf와 같다면, 대체를 멈춰야 한다. 그 이유는, 함수 내부에서 그 매개변수는 새롭게 바인딩되기 때문이다. 그래서 expr(즉, 함수 자체)을 그대로 반환한다.
예를 들어
with {x 3} {fun {x} {+ x y}}
이건 대체되는 x와 함수 인자 x가 같기 때문에 대체를 멈춘다.
with {x 3} {fun {y} {+ x y}}
이건 인자와 대체되는 값이 서로 다르기 때문에 대체를 진행하여 결과가 Fun(Id("y"), Add(Num(3), Id("y"))) 이렇게 된다.
자 그러면 이제 중요한 부분 또 알아보자
변수 대체가 어떻게 함수 안에서 알아서 진행이 될까?
subst(With(Id(y), Num(10), Id(z)), Id(z), Fun(Id(x), Add(Id(x), Id(y))))
이 부분에서 z라는 변수를 함수 fun {x} {+ x y}로 대체하려는 거다.
즉, z가 Fun(Id(x), Add(Id(x), Id(y)))로 바뀌는 과정이다.
대체 후에는 이렇게 바뀐다.
With(Id(y), Num(10), Fun(Id(x), Add(Id(x), Id(y))))
이 식은 with {y 10} {fun {x} {+ x y}}가 되는 거다. 이제 y가 함수 안에서도 나오는 상황이다.
이제 함수 내부의 y를 찾아 대체해주면 다음과 같다.
Fun(Id(x), Add(Id(x), Num(10)))
결론적으로 대체하려는 변수가 함수 내부에서 자유 변수(즉, 함수 안에 바인딩되지 않은 변수)일 때는 그 변수를 계속 찾아서 대체해줘야 한다. 그래서 y가 함수 바깥에서 정의된 값이니까, 함수 본문에서도 그 값을 찾아서 바꿔준 거다.
이제 일급 함수를 배우니 더 이상 with를 사용할 필요가 없어보인다.
함수 자체가 with 의 기능과 완벽히 일치할 수 있기 때문이다.
하지만 우리는 계속 with를 콘크리트 문법으로써 사용할 것이다.
왜냐하면 Snytax sugar로써 사용할 수 있기 때문이다.
그럼 이제 이렇게 일급함수로 바꿨으니까 대체의 연기 과정도 변경시켜줄 필요가 있다.
trait ExprValue
case class NumV(n: Int) extends ExprValue
case class ClosureV(param: Id, value: Expr, ds: DefrdSub) extends ExprValue
trait DefrdSub
case object MtSub extends DefrdSub
case class ASub(name: Id, value: ExprValue, saved: DefrdSub) extends DefrdSub
여기서 ClosureV와 ASub가 중요한 역할을 한다.
ExprValue는 값을 표현하는 추상 클래스다. 이 클래스를 상속받는 두 가지 값 타입이 있다.
- NumV(n: Int): 숫자를 값으로 표현하는 클래스. NumV는 Num과 달리, 해석된 숫자 값을 의미.
- ClosureV(param: Id, value: Expr, ds: DefrdSub): 클로저(Closure)를 표현하는 값. 함수 정의가 클로저로 저장되는데, 여기서 중요한 건 이 클로저가 변수를 바인딩한 환경(ds)도 함께 저장한다는 거다. 이렇게 하면 함수가 정의될 당시의 환경을 기억하고, 호출될 때 그 환경을 참고해서 변수 값을 사용할 수 있다.
DefrdSub는 지연된 대체(Deferred Substitution)를 관리하는 구조다.
- MtSub: 비어 있는 대체 환경. 더 이상 대체할 것이 없을 때 이걸 사용.
- ASub(name: Id, value: ExprValue, saved: DefrdSub): 특정 변수 name이 value로 대체될 때 사용. 여기서 saved는 기존 대체 환경을 저장하고 있다, 대체할 값들이 계속해서 누적되는 구조.
예시를 통해 작동 방식을 확인하자
interp(parse("{with {y 10} {fun {x} {+ y x}}}"), MtSub)
이 코드의 해석 과정은 이렇게 진행된다
- with {y 10}: y라는 변수를 10으로 바인딩. 이때 y의 값이 대체 환경(ASub)에 저장.
- fun {x} {+ y x}: 함수가 정의될 때, 현재의 대체 환경을 함께 저장해서 클로저가 만들어진다. 이 클로저에는 y = 10이라는 정보가 포함된다. ClosureV는 param = "x", value = {+ y x}, 그리고 대체 환경 ds = ASub("y", NumV(10), MtSub)로 표현.
- 이 클로저를 나중에 사용할 때도, 그 함수가 정의된 당시의 y = 10이라는 바인딩 정보가 유지되기 때문에, 동적 스코프(dynamic scope) 대신 정적 스코프(static scope)가 보장된다.
이제 이 모든 것을 때려박으면 다음과 같은 인터프리터가 된다.
def interp(expr: Expr, ds: DefrdSub): ExprValue = expr match {
case Num(n) => NumV(n)
case Add(l, r) => numAdd(interp(l, ds), interp(r, ds))
case Sub(l, r) => numSub(interp(l, ds), interp(r, ds))
case Id(s) => lookup(Id(s), ds)
case Fun(p, b) => ClosureV(p, b, ds)
case App(ftn, arg) =>
val f_val = interp(ftn, ds) // 함수를 평가
val a_val = interp(arg, ds) // 인자를 평가
f_val match {
case ClosureV(param, body, closure_ds) =>
interp(body, ASub(param, a_val, closure_ds))
case _ => throw new SimpleException("Expected a function")
}
}
Fun(p, b) => ClosureV(p, b, ds) 이렇게 함수를 정의하면, 함수는 함수 본문과 정의된 환경을 함께 기억하게 된다. 이 클로저는 함수 호출 시 이 환경을 참조하게 돼서, 바깥에서 정의된 변수를 잊지 않게 되는 거다.
App(ftn, arg) 부분은 그 함수가 ClosureV인지 확인한 후, 본문을 실행한다.
case ClosureV(param, body, closure_ds)에서 ClosureV가 발견되면, 이제 함수 본문(body)을 실행한다. 여기서 중요한 건, 함수를 정의했을 당시의 환경(closure_ds)이 있다는 거다.
case _ => throw new SimpleException("Expected a function") 이건 함수가 아닌 값이 함수처럼 호출됐다는 뜻이다.