Functional Reactive Animation - Conal Elliott and Paul Hudak, 1997 논문을 본 분들을 대상으로 삼는 글입니다. 무려 27년 전 글이지만, 아직도 많이 읽힌다고 하는데, 검색에는 딱히 걸려드는 한글 자료는 없습니다. 수많은 선행자들이 이해하고 넘어 갔을텐데 자료가 없는 게 아쉽습니다.
b1 untilB (lbp t0) ==> λe.b2 untilB e -=> b3
절차형에서는 여러 단계로 되어 있는 if
문들이 함수형에선 모두 사라진 느낌의 표현입니다. 이게 익숙하게 보이지 않아, 람다 산법에서 if
문을 어떻게 처리하는지 다시 상기해 봤습니다.
if
문은
“외부에서 들어온 값”을
“값을 받아 true
또는 false
로 판단하는 구문”을 거치고,
“if
구문”으로 true
일 때 실행할 코드, false
일 때 실행할 코드로 진행합니다. –(가)
람다 산법에서 true
는 select_first
로, false
는 select_second
로 정합니다. ※ 참고 람다 대수 기본 용어 - 분기문
실행할 코드 두 가지를 튜플로 가지고 있고, 외부에서 “튜플 중 하나를 선택하는 함수”를 받는 고차 함수를 정의합니다. 고차 함수에 select_first
또는 select_second
를 넣어줘서 진행할 길을 결정합니다. –(나)
좀 더 추상적으로 정리하면, 함수형에서는 가능한 경우의 수에 따른 코드를 튜플로 준비해 놓고, 튜플의 위치를 고르는 함수를 받아, 그 함수를 튜플에 적용해서 분기를 구현하고 있습니다. 값으로 표현하던 true
가 함수가 됐으니, 고차 함수는 이 함수를 적용할 값을 가지고 있어야 합니다.
처음 볼 때는, 값과 함수의 역할이 뒤바뀌는 식으로 생각해야 하나 싶었는데, 잘 따져 보면, 두 개가 그리 달라 보이지 않습니다.
값 true
를 받았다면, 컴비네이터 안에는 값에 적용할 함수가 있고,(if
구문을 별도의 함수로 생각)
함수 true
즉, select_first
를 받았다면, 컴비네이터 안에는 이 함수를 적용할 값을 가지고 있습니다.
분기란 건, “조건”과 “조건에 따라서 실행될 코드”, 그리고 둘을 매핑해주는 역할이 필요합니다. (이 역할을 런타임이라 보겠습니다.)
매핑해주는 역할을 true
에 심어 놓든가(함수형의 select_first
), 별도 함수(절차형의 if
)로 두든가의 차이입니다.
프로그래밍은 매핑한 결과를 또다른 실행 코드와 매핑하고, 그래서 나온 결과를 또 다른 실행 코드와 매핑하는, 매핑의 반복으로 이루어져 있습니다. 매핑할 때 분기를 하든가, 갈 길이 하나뿐이 없는 시퀀싱으로 매핑하든가 합니다. 절차형이 됐든, 함수형이 됐든 이 동작으로, 현실을 모델링하는데는 차이가 없습니다. 매핑 결과를 다음 매핑에 어떻게 넘겨 주냐의 차이만 있을 뿐입니다.
절차형에선 프로시저가 작업이 끝나면, 작업 결과를 메모리에 넣어두고, 항상 다음 프로시저로 진행합니다. 다음 프로시저를 정하는 건 런타임이 합니다. (내부 구현은 함수형과 같이 CPS로 되어 있을 수도 있겠습니다.) 함수형에선 함수가 작업이 끝나면, 작업 결과를 바로 다음 함수에 넣어주며 진행합니다. 다음 함수를 정하는 건 함수 자체가가 정합니다. (CPS를 알고 있다면, 함수형은 CPS로 이루어져 있다고 말할 수 있습니다.)
2.3 Semantics of Events 섹션에 External events 부분에 아래 예시가 나옵니다.
b1 untilB (lbp t0) ==> λe.b2 untilB e -=> b3
untilB :: Behavior α -> Event (Behavior α) -> Behavior α
-=> :: Evant α -> β -> Event β
==> :: Evnet α -> (α -> β) -> Event β
lbp, rbp :: Time -> Event (Event ()) -- 마우스 클릭
결합 순서를 뜻하는 괄호는 없지만, 타입을 고려하면, 아래처럼 해석해야 말이 됩니다.
마우스 버튼이 눌리기 전에, 위 체인의 결과는 b1
마우스 버튼이 떼지기 전에, 위 체인의 결과는 b2
마우스 버튼이 떼어진 후에, 위 체인의 결과는 b3
예를 들면 상태가 b1
인데, 마우스 버튼을 누르면 b2
가 되고, 그 후 버튼을 떼면 b3
가 되는 동작을 모델링한 체인입니다. 어떻게 이렇게 해석되는지 하나 하나 뜯어 봤습니다.
-=>
는 Event α
와 값β
를 받아 Event β
를 돌려주고, (Event
가 가지고 있는 값을 바꿔치는 작업을 합니다.)
==>
는 Event α
와 함수(α -> β)
를 받아 Event β
를 돌려 줍니다. (Event
가 가지고 있는 값을 바뀌주는 함수를 받아 값을 변환하는 작업을 합니다. 폴리모픽으로 타입을 규정하고 있진 않지만, 위 사용례를 보면 α
는 Event
타입을 받고, 이 Event
가 발생하면, 조건에 따라 Behavior
를 결정해서 β
로 내뱉는 함수가 주로 올 것 같습니다.)
-=>
와 ==>
는 Event
컴비네이션을 만들어 내는 컴비네이터들로 최종 결과는 Event
를 만들어 내는 함수들입니다.
매직같은, 번뜩이는 아이디어는 λe.b2 untilB e -=> b3
부분입니다. lbp t0
는 Event ()
가 아니라 Event (Event ())
값을 만들어 냅니다. e -=> b3
는 안에 있는 Event ()
의 값을 바꿔 놓는 역할을 합니다.
순서를 가진 Event
들은 Event(Event(Event ...))
모양으로 표현할 수 있습니다. 버튼 떼기는, 버튼 누르기 후 일어납니다. Event(Event ())
로 표현하고 있습니다.
아무도 일도 일어나지 않으면 b1
,
바깥 Event
까지만 일어났다면 b2
,
안 쪽 Event
까지 일어났다면 b3
입니다.
b1
Event(Event ())
중 안에 있는 Event ()
를 λe.b2 untilB e -=> b3
에 넘겨 줍니다.e -=> b3
로 이벤트e
가 가지고 있는 ()
를 b3
로 바꿉니다.Event b3
가 아직 일어나지 않았다면, 첫 번째 untilB
의 결과(전체 체인의 결과)가 b1
대신 b2
, 이벤트가 일어났다면 b1
대신 b3
가 됩니다.마치 if
문이 없는 것처럼 보이지만, untilB
에서 이뤄지고 있습니다.
절차형으로 위 동작을 표현하려면 어찌할지 생각해 봤습니다.
if (마우스 버튼 클릭) then if (마우스 버튼 릴리즈) then return b3 else return b2 else return b1
절차형에 비해 생각이 걸리적 거리는 이유는 버튼 클릭 -> 버튼 릴리즈
처럼 순서대로 가지 않고, 생각 흐름과 일치하지 않는 것처럼 보일 수도 있습니다. 하지만, if
가 untilB
에 숨은 것을 생각하고 보면 절차형과 그리 달라 보이지 않습니다. 잘 보면 ==>
는 두 번째 인자로 α -> β
함수를 받습니다. 여기서는 Event () -> b2 혹은 b3
이 됩니다. 의사 코드로 쓰면
b1 until 클릭 이벤트
b2 until 릴리즈 이벤트 b3
이런 느낌입니다. 이렇게 봐도 아직 편하진 않습니다.
어느 한 쪽에서 무슨 일이 일어났는지 아는 방법은
- 일이 일어난 쪽에서, 청자에게 알려 주든가,
- 청자가(혹은 제3자가) 계속 일이 일어났는지 물어 보든가
두 가지 방법뿐이 없습니다.
일등급 시민으로 타입(Event
,Behavior
)을 설계하고 그냥 함수로 일반 값 다루듯이 하는 게 언뜻 이해가지 않았습니다. 아마도 옵저버블 패턴(리스너 패턴) 자체가 어떻게 함수형으로 구현되는지 너무 일찍 궁금해 해서 그런 듯 합니다. 위 untilB
체인은 프레임마다 계속 실행하는 걸로 생각하고 읽으면 됩니다. 리스너를 쓰지 않는다는 말이 아닙니다. 나중 외부 이벤트와 붙이게 되면, 리스너를 써야만 합니다. 논문에서 구현 전에 나오는 내용은 리스너와 붙이는 것을 대체하는 게 아닌, 리스너로 통지를 받은 후에 전파되는, 일종의 이벤트 네트워크를 표현하는 방식에 관한 얘깁니다.
Event
타입은 특별한 동작이 있습니다. Event
가 발생하기 전엔 코드가 진행되지 않게 막을, 블록할 방법이 필요합니다. FRAN에선 바로 언급하지 않고 있지만, 저는 구현을 생각하며 쫓아가는 게 이해하기 편했습니다. 구현에선
Event
값이 Just
인지 Nothing
인지 봐서, Nothing
이면 더 이상 코드 진행을 하지 않고 끝내거나,Event
값이 MVar
나 IORef
등으로, 자체적으로 값이 생길 때까지 블록하는 능력을 쓰는 방법이 있겠습니다.첫 번째 방법을 pull, 두 번째 방법을 push로 부르는 것 같습니다. push 방법은 바로 전체 체인의 결과가 바로 나오는 게 아니니, 체인 중간 중간 결과가 의미있게 드러난다면 가능할 것으로 보입니다. 어차피 MVar
나 IORef
를 써야하니, 이펙트에 의미를 잘 심으면 될 것으로 보입니다.
FRAN 논문의 2.1 Semantic Domains
아래는 추측입니다.
pull로 구현한 상황을 보겠습니다. Event
가 3.5초
에 일어났다고 보겠습니다. 0.5초
단위로 샘플링하고 있었다면, 이 이벤트는 정상적으로 캡처됐겠지만, 만일 1초
단위로 샘플링하면 3초
에 샘플링할 때도 이 값은 없고, 4초
에 샘플링할 때도 이 값은 없습니다. 이 Event
를 놓치지 않는 방법을 생각해 봤습니다.
4초
에 샘플링을 할 때 볼 수 있어야 합니다.3.5초
가 아니라, 3.5초 이상에 일어났다고 정의하고 있습니다. 항상 3.5초
보다 큰 다음 샘플링 시각이 최상위 값입니다.이렇게 정의하면 샘플링 단위 시간에 상관없이 이벤트를 놓치지 않을 수 있습니다. 벡터 그래픽을 비트맵으로 만드는 것과 비슷한데, 너무 일찍 비트맵으로 만들어 버리면 데이터를 잃어버리는 양이 늘어 나듯이, 최대한 시각이 아닌, 시간, 즉 범위로 정보를 끌고 다녀야 합니다.
샘플링에 상관없이 결과를 내려면, 확정된 하나의 값이 아닌, 최대한 범위로 정보를 들고 다녀야 합니다. 이렇게 하기 위해 논문에선, 시간을 집합론의 Order Theory로 정의하고 있습니다.
1 ⊑ 2 ⊑ 3.5이상 ⊑ 4
3.5 이상
이면 4
일지 더 큰 수일지 알지 못하지 않나 싶은데, 위 처럼 순서가 나오도록 다음과 같이 정의해 놨습니다.
x ≤ y이상 if x ≤ y
x이상 ≤ y 는 undefined x이상 ≤ y이상 undefined
처음 볼 때 금방 이해가 안갔습니다. x이상
,y이상
을 하나의 수처럼 바라보긴 하는데, x이상
이 y
보다 작을지 클지는 모른다는 직관 그대로 undeinfed
라 정의해놨습니다. denotational한 의미를 정의하는데 중요한 역할을 하는 정의라고 하는데, 천재적인 것 같기도 하고, 말장난 같기도 합니다. 어쨌든 시간의 크기보단 순서가 의미가 있다 정도로 넘어갔습니다.
⊑
는 크기가 작다는 뜻이 아닙니다.(이름은 Square Image of or Equal To 라 부르는 기호입니다.) 순서상으로 먼저와 나중을 표현하는 기호입니다. 3.5
는 4
보다 작지 않을지 몰라도 순서상으론 앞에 오도록 약속, 규칙을 정의합니다. 만일 샘플링을 0.2초
단위로 한다면 3.5
는 3.6
보다는 (작은 게 아니라) 먼저라는 뜻입니다. “어떤 수 이상”이란 원소들은 자신이 속한 샘플링 구간을 넘지 않는다는 약속이니, 어떤 샘플링 단위에서도 정보를 잃어버리지 않는다로 이해하고 있습니다.
현재 구현된 FRP 프레임워크들 중에 퍼포먼스도 잘 나오고, Continuous 시간도 이론대로 구현한, 두 마리 토끼를 다 잡은 프레임워크는 없다는 것 같습니다. 코널 엘리엇 교수의 여기 저기 글을 보면, FRP는 denotational하고, Continuous 시간을 표현, 구현해야 제대로 FRP라 할 수 있는데, 그런 의미에선 아직은 제대로 실용 가능한 퍼포먼스를 보이는 FRP프레임워크는 없다는 뜻입니다.