FRP의 Event와 Behavior (작성 중)

Posted on October 11, 2023

FRP 전체에 대한 얘기는 아니고, FRP 이해의 출발점인 EventBehavior를 이해가 목표입니다. Event 개념보다는 주로 Behavior에 대한 상상, 해석입니다.

사실, 그리 깊게 알고 싶진 않고 구현 아이디어와 사용법 정도 알고싶을 때가 많은데, 하스켈의 자료들은 여차하면 논문으로, 여차하면 소스 코드로 끌고 들어갑니다. 그런 곳으로 잘 못 끌려가면 카테고리 이론 용어들이 떡하니 버틴 경우도 많고, 처음 보는 문법으로 풀어 나가는 코드도 보게 되곤 합니다.

저런 곳으로 끌려가지 않기 위해, 서두의 내용만 갖고, 상상 했던 내용과 아귀를 맞추려고 시도합니다. 그러다 그럴싸하게 맞아 떨어지면 맞는 내용이겠거니 하고 넘어갑니다. 혹시나 의논을 할 수 있는 분을 만날까 해서 블로그에 글도 올려 놓고요. 그러니, 틀리는 경우가 종종 있을 수 밖에 없습니다. 틀리거나 의심스런 내용이 보이면 댓글, 혹은 메일 꼭 부탁드립니다.

새로운 프로그래밍 개념을 공부할 때, 먼저 상상해 보곤 합니다. 이러고 개념을 보기 시작하면 단점이 하나 생깁니다. 제 상상이 일종의 기준이 되어 계속 맞춰보게 됩니다. 기준이 크게 무리가 없는 상상이면 굉장히 도움이 되지만, 반대의 경우도 생깁니다.

Sat Feb 17 12:15:27 AM KST 2024 아직 아래 생각이 맞는지 확신이 없습니다. 어설프게 알고 있는 테크닉들이 아귀가 맞아 오해하고 있을 수 있습니다. 검증이 되면 이 메시지를 지우겠습니다.

생각 스트레칭

“버튼이 눌리면 빨간색 연필로”
버튼이 눌리는 이벤트, 연필 색깔 빨간색으로 변경합니다.

여기에 “5초가 지나면” 빨간색으로 바꾸는 이벤트를 추가해 보겠습니다.

“버튼이 눌리거나, 5초가 되면 빨간색 연필로”
이벤트가 두 개가 되었습니다.

이 상황을 모델링 해보겠습니다.

if (evButton == True | ev5sec == True) Then Color = Red
-- 혹은 
if (evButton ==True) Then Color = Red
if (ev5sec==True) Then Color = Red

여기에, “키를 누르면 빨간색 연필로” 를 추가하려면,

if (evButton == True | ev5sec == True | evKey == True) Then Color = Red
-- 혹은 
if (evButton == True) Then Color = Red
if (ev5sec == True) Then Color = Red
if (evKey == True) Then Color = Red

위 조건을 확인하는 절차를 가변적으로 유지할 방법이 뭐가 좋을까요? 조건이 하나든, 열 개든 하나의 모양(타입)으로 볼 수 있다면 편할 것 같습니다. 함수형에선 가변 정보를 끌고 다닐 때는 튜플속 튜플같은 재귀적인 구조, 즉 리스트같은 구조에 담아 두면서 갈 수도 있겠지만, 좀 더 편한 인터페이스가 있습니다.
바로 함수컴비네이터입니다.

한 가지 모양으로 보기 Combination

여러 조건들은 합쳐져서Combination 하나의 조건처럼 다룰 수 있어야 합니다. .add., .or., 그리고 그외 로직에 필요한 컴비네이터들을 정의합니다. Red로 바꿀 이벤트들은 모두 합쳐 하나의 이벤트로 만듭니다.

evRed = evButtonFunc .or. ev5secFunc .or. evKeyFunc

각 이벤트가 발생하면 ev~ 변수들에 True를 담아 놓는 대신, 바로 ColorRed로 바꿔 놓으면 되겠습니다.

“연필 아이콘을 빨간색으로 바꾸기” 와 같이 바꿔야 되는 값을 더 추가해 보겠습니다.

값도 역시 컴비네이터 스타일로 처리합니다. 값을 위한 컴비네이터는 :add:,:or: 쯤으로 표기하겠습니다.

valRed = valIcon :and: valColor

그럼 최종 로직은, evRed가 발생하면 valRed에 있는 작업을 합니다.
이벤트에 따라 값을 바꿔 놓는 apply같은 일을 하는 이벤트 -> 값 같은 변환 함수를 watcher라 하면

watcherR(evRed, valRed)

만일, 파란색으로 바꾸는 기능도 들어간다면, watcher들을 붙일 수 있는 컴비네이터 :>>:를 만듭니다.

watcher = watcherR(evRed, valRed) :>>: watcherB(evBlue, valBlue)

이제, 샘플링 단위 시간마다 watcher를 실행하면 됩니다.

아직 값은 아니다. 값이 될 타입

위와 같은 컴비네이터 패턴으로 여러 이벤트와 상태들을 물려서 돌아가게 할 수 있게 되었습니다. 여기에, 타입을 넣으면 좀 더 그럴싸해집니다.

이벤트가 아직 발생하지 않았지만, 이벤트가 일어났을 때 할 일을 지정해 놓는 방법으로 다음과 같이 할 수 있습니다.

data WillBeValue a = WillBeValue a

그리기 함수는 Color를 읽어와 그립니다. 그런데 Color는 지금 정해진 값이 아니라, 이벤트에 따라 변할 값입니다. 이벤트가 발생했을 때 IORefMVar등에서 색을 읽어 오게 만들어 두거나, 스트림 함수 같은 걸 이용해서, 매 번 프로그래머가 신경쓰지 않게 할 수 있습니다. WillBeValue가 가진 a값을 보기위한 절차WillBeValue_Runner에 넣어두면 다음과 같이 쓸 수 있습니다.

--   :: a -> IO () 가 아니라
draw :: WillBeValue a -> IO ()
draw future = do
  color <- WillBeValue_Runner future 
  color로 그리기 

나중 draw를 쓰는 입장에서 보면, “WillBeValue 색깔로 그려라”라는 간단한 구문이 되었습니다. 폴링 파트에서 계속 draw를 호출하면 되는 간단한 모양이 되었습니다.

그리고 Lazy 성질로 WHNF까지만 보니, WillBeValue가 안에 가지고 있는 값은 필요할 때 계산하게 될 겁니다.

IORefMVar등을 안쓴다면, t -> a 함수 자리에 스트림용 함수(순환 함수)를 넣어 줄 수도 있겠습니다. 실제 구현에선 t -> a로 구현한 프레임워크는 아직 못 봤습니다.

샘플링과 스트림

만일 1초마다 샘플링 하는 설계라면 1초 안에 더블 클릭 했을 때는 한 번 클릭한 것만 받을 수 있을 겁니다. 1초 마다 샘플링해도 1초 미만 간격으로 누른 더블 클릭을 잡아내려면, 이벤트를 버튼 눌림이 아니라 [버튼 눌림] 리스트로 받으면 될 겁니다. 이벤트 값을 받아서 뭔가를 하던 작업들은

draw :: [WillBeValue a] -> IO ()

리스트(스트림)를 받도록 바꾸고, 샘플링 단위 시간 만큼의 이벤트만 꺼내도록 만들면 되겠습니다. 이벤트는 샘플링 단위 시간과 관계없이 발생하고, 이를 최종 어떤 단위 시간으로 샘플링하든 더블 클릭을 잡아낼 수 있습니다. 코널 교수가 얘기한 벡터 그래픽과 비트맵의 관계가 이 걸 말하는 듯 한데, 아직 확실하지 않습니다. 이벤트는 샘플링 시간에 관계 없이 발생하고(마치 벡터), 이 걸 샘플링 해서 보여주는 작업(마치 비트맵)을 합니다.

타입에 시간 개념 집어 넣기

아래는 이론을 설명할 때 등장하는 타입들로, 이 글의 주인공들입니다.

newtype Event a    = Event    { occ :: (Time, a) }
newtype Behavior a = Behavior { at  :: Time -> a }

텍스트에선 실제가 아닌 이론적인 두 가지 형태를 먼저 설명하고 있습니다. Event는 “특정 시간에 일어난 일”이고, Behavior는 시간마다 바뀔 수 있는 (mutation) 값인데, 언제고 알고 싶은 시각을 주면 값을 알려주는 함수입니다. 위에서 나온 Color같은 ValueBehavior로 보면 됩니다.

다음도 역시 실구현이 아닙니다만, 개념을 이해하기 위해 더 직관적으로 보면 아래처럼 볼 수도 있습니다. BehaviorEvent를 굳이 나누지 않고 하나의 타입으로 처리할 수도 있을 것처럼 보입니다.

Event는    [_,_,_,_,e,_,_,e,_,...]
Behavior는 [b,b,b,b,b,b,b,b,b,...]

어떤 순간에도 값이 존재하는 것과, 특정 순간에만 값이 존재하는 것. 위 occ, at보다 더 의미가 직관적으로 보이지 않나 싶은데요. 원소를 Maybe로 모델링하면, BehaviorJust 값으로만 이루어진 걸로 보면되니, 위 둘을 하나로 추상화할 수도 있습니다. Yampa는 둘이 합쳐 하나의 Signal로 모델링했고, Reactive-banana는 두 가지로 나누어서 모델링 했습니다.

다음 원소로 진행하는 걸 단위 시간 한 스텝을 나아가는 걸로 해석하고 있습니다.

텍스트들의 도입부를 읽고, 내가 구현한다면, 어떻게 할까 생각해 봤습니다. 변하는 시스템을 모두 반응Reaction으로 표현한다면

E1   ->   E2   ->   E3 

이벤트들이 연쇄적으로 반응Reactive하며 이어가는 동작이 있겠고,
(※ 값이 유지되지 않는다는 뜻으로 화살표를 짧게 했습니다.)

E1 (혹은 특정 시각)            E2나 시간등의 변경 요인          -- Discrete하게 변경
                |                         |
       Behavior B1 -----------+-----------------+------------>  -- Continuous하게 유지
                              |                 |
                          값이 필요해서 들여다 보는 순간        -- Discrete하게 읽기 

(“시작”같은 “특정 시각”도 시간 이벤트라 볼 수 있으니) 이벤트로 시스템에 변경을 가하면, 시간이 흘러가면서 지속적으로 바뀌는 정보를 생각해 볼 수 있습니다. 물론 꼭 계속 바뀌지 않고, 한 번 바뀐 값으로 계속 가는 “유지”도 포함입니다. 이런 것들은 시간에 따라 계속 바뀌고 있지만, 바뀌는 값을 실제로 꼭 계속 업데이트 할 필요는 없습니다. 필요한 순간에 t -> at값을 넘겨 함수를 실행해서 값을 받아오면 됩니다.

지금까지 말한 내용이 그대로 이론이나 구현과 딱 맞아 떨어지는 것이 아닙니다. 어디까지나 수학적 해석없이 이벤트 전파 길(혹은 그래프, 혹은 네트워크)을 어떻게 만들 것인가 생각 스트레칭을 해 본 것입니다. 여기에선 EventBehavior의 실제 구현을 보는 게 아니라, FRP 프레임워크를 설계하면서 이들이 어떤 걸 모델링 했는지, 의미를 보려 합니다.

Event (혹은 Event Stream)

참고 - Reactive-banana의 학습용 소스 - Model.hs

구현을 생각하면, (발생 시각, 값) 두 가지 정보만 가지고 있습니다. 시간을 인자로 주거나 하는 게 아닙니다. 여러 시간이 아니라, 특정 시각에 변화를 주는 일이 일어났을 뿐입니다.

Behavior

Event는 그리 어렵지 않게 넘어 갔는데 Behavior는 설명들이 혼란을 줍니다. 얌파는 이 둘을 구분하지 않고 Signal이란 하나의 타입으로 구현했습니다. 둘은 성격이 달라 보이는데, 하나로 본다니 금방 수긍이 가지 않습니다. 이벤트 발생과 지속을 구분하는 게 무슨 철학적인 접근인가 싶기도 합니다.

시간에 따라 변하는 값입니다. (실제 구현은 아니고) 의미로는 t -> a로 볼 수 있다고 합니다. (semantic function)

생각 스트레칭
Q. Event와 다르게 연속Continuous적인 시간에 벌어지는 일이라고 합니다. 결국 컴퓨터로 표현하려면 모두 discrete하게(불연속, 이산) 바꿔야 하는 것 아닌가요?

실제 구현이 연속Continuous적인 동작을 한다는 게 아니라, 시간에 연속인 대상을 모델링을 했다는 뜻일 뿐, Behavior가 연속으로 계속 업데이트 하고 있는 모양은 아닙니다. Behavior의 값을 가져다는 쓰는 쪽에서 연속이란 의미는, 바꿔 말하면, 언제든 “값을 원하는 순간”에 값을 얻을 수 있다는 말입니다. 무엇이 일정 시간동안 “존재”한다는 뜻에는 Continuous란 의미가 들어가 있다는 생각이 듭니다.

Q. Behavior는 시간의 흐름을 강조합니다. FRP가 내부적으로 흘러가는 타이머를 두는 건가요?
추측 - 폴링 작업이 들어가면 타이머가 돈다고 볼 수 있겠습니다. 항상 변경되는 걸 결과로 바로 보여야 하는 건 아니니, 그럴 수도 아닐 수도 있습니다. 만일 실시간으로 계속 값을 가져와야 한다면 타이머가 돌아야 합니다. 참고 - Push-Pull FRP - Conal Elliot 섹션5.1
보통 예시들을 보면 입력을 스트림으로 주는데, [_, _, _, ...]에서 원소 하나를 가져와 작업하고, 다음 원소를 가져오는 걸 시간이 흘러간 것으로 표현하는 곳도 있습니다.

Behavior가 FRP에서 일종의 기억 장소 역할을 하는 것으로 이해했습니다. 시간에 연속적으로 변할 수 있는 값이긴 하나, 실 구현에선 (매 단위시간마다 업데이트를 하고 있는 게 아니라) 적절한 동작이 필요하겠습니다. 마치, FRP의 원조처럼 여기 저기 거론되는 FRAN에선, 이론대로

data Behavior a = Behavior (Time -> a)

로 표현하고, 현재 시간의 값을 알려면 처음 시작 시간부터 지금까지의 히스토리를 추적해 값을 만드는 것으로 되어 있습니다. 이렇게 하면 당연히 공간 시간 낭비가 너무 심하니, 적절한 트레이드 오프가 필요하다고 합니다.

Functional Reactive Programming - Haskell Wiki에 보면 위젯을 1급first class 값으로 다루는 것에 대한 얘기가 나옵니다. Lazy 평가를 기본으로 하는 하스켈에서 Behavior를 시간에 대한 함수로 모델링하면, 다음과 같은 코드 모양이 가능합니다. WHNF까지만 reduce하는 Lazy가 영향이 있을 것 같긴 한데, 아직 정리가 안됩니다.

myEditWidget :; Behavior Text

do edit1 <- editWidget
   edit2 <- editWidget
   label <- label (liftA2 (<>) edit1 edit2)

editWidget이 바뀌면, 알아서 myEditWidget이 바뀌길 바라지만, 그렇게 실행되는 건 아닙니다. 실행 시작은 myEditWidget입니다. editWidget이 바뀌는 이벤트가 발생하면, myEditWidget를 실행하도록 해놔야 합니다. 이 건 프레임워크 바깥에서 해결할 일입니다. 위와 같은 함수는 계속 폴링할 때 실행한다는 걸 전제하고 있는 듯 합니다.

예시

마우스는 움직일 때마다 Event가 발생하고, 이 EventBehavior에 마우스 위치를 저장합니다. 마우스 위치값이 필요하면 이 Behavior를 읽어 오면 됩니다. 값이 필요할 때 직접 Event에서 값을 받아 오지 않는 이유가 뭘까요? 마우스가 멈춰 있다면 Event는 발생하지 않지만, 위치값은 존재해야 합니다.

Behavior의 시간은 Continuous하게 흐르지만, Behavior의 값이 변경되는 시점과, 값을 필요로 하는 시점은 Discrete합니다.
반면, Event의 시간은 Discrete하고, Event의 값을 받아가는 시점은, Event가 발생한 순간입니다.

※ Reflex에는 위 두개에 더해 Dynamic이란 타입이 있습니다. Behavior처럼 모든 시간에 값을 가지는데, 가지고 있는 값이 바뀌면 외부로 알릴 수 있습니다. 기본적인 구현은 EventBehavior를 튜플로 가지고 있습니다.

참고 - Reflex Basics @todo 작성 중 …

연속된 시간

연속된 시간을 어떻게 표현할 수 있을까요?

연속된 시간을 다른 말로 풀면, 어떤 시간에도 값이 존재한다고 말할 수 있습니다. 이 풀이는, 어떤 시간과도 매핑되는 값이 있는 함수로 볼 수 있다로 연결됩니다. 그럴만하다고 끄덕하게 되는 Representation입니다.

t -> a

Conal Elliot 교수는 연속된 시간을 비트맵과 벡터 그래픽에 비유하기도 합니다. 비트맵은 정해진 해상도가 아닌 곳에서 보면 일그러지지만, 벡터 그래픽은 어떠한 해상도에도 대응할 수 있습니다. 여기에 제 생각을 더하자면, 비트맵이란 벡터 그래픽을 특정 해상도에 맞춰 해석한 값입니다. 벡터 그래픽 :: 해상도 -> 비트맵 그래픽 쯤으로 볼 수 있습니다. 결국 눈으로 보는 건 해상도에 맞춰진 비트맵을 보는 걸로 생각할 수 있습니다. 반대 방향에서 들어가며 보면, 만일 t -> a로 문제가 생기지 않는 모델링을 할 수만 있다면, 연속된 시간을 모델링했다고 말할 수 있겠습니다.

stackoverflow에 있는 Conal 교수 답변
What is (functional) reactive programming?
evolving value1

first class value2

Specification for a Functional Reactive Programming language

a와 t -> a의 차이

(가)

int1 = 1
int2 = 2
add x y = x + y 
> add int1 int2

(나)

like1 = \t -> case t ... 
like2 = \t -> case t ...
likeAdd f g = \t -> f t + g t 
> likeAdd like1 like2 $ ()

둘은 무슨 차이가 있을까요?

(가)와 같이 해도 addint1int2에 의존합니다. 단순히 의존성만 필요하다면 굳이 (나)와 같이 할 필요가 없습니다. add가 실행되는 순간 int1int2도 바뀔 일은 없으니, 그대로 값을 얻을 수 있습니다. 다르게 얘기하면, add가 실행된 후에는 int1이 바뀌어도 int2가 바뀌어도 add값이 달라지지 않습니다. (나)의 likeAdd는 다릅니다. (가)와는 달리 likeAdd를 실행 후에도 like1like2가 바뀐다면, likeAdd값, 엄밀히 말하면 likeAdd 자체는 함수니 달라지는 건 없지만, 함수를 실행 후 받는 최종 결과값은 달라질 수 있습니다. 오호! 마치 최종 결과에 도달했는데도 원인이 바뀐다면 , 즉 언제든지 like1이나 like2가 바뀐다면 값이 바뀔 수 있습니다. 단, 함수를 실행, 즉 runner를 거쳐야만 변하는 값을 볼 수 있지만 말입니다. 이 상태에선 likeadd는 그냥 함수니 하스켈에선 일등급 시민입니다. likeAdd를 그냥 값처럼 쓰면 됩니다. 나중에 필요할 때, runner를 돌리면 likeAdd값을 결정 지을 수 있습니다.

리액티브 모델링은 이벤트가 발생하면, 리스너를 실행시켜 상태를 바꿉니다. 상태는 이벤트에 의존합니다. 언제라도 이벤트가 바뀌면 상태도 바뀝니다.
리액티브를 구현, 표현하기 위해 필요한 건, 인과 관계 표현입니다.

위 설명과 아래 설명이 추상적으로 보면 같은 얘기로 보입니다. 함수형Functional으로 리액티브를 구현한다면 이보다 적당한 표현이 없지 않을까 하는 생각이 듭니다.

AddListner하는 절차 없이 이벤트 발생을 어찌 전파하지?

어떻게 핸들러 등록같은 코드없이 인과 관계causal를 표현하는지 궁금했습니다.

외부 이벤트와 FRP 프레임워크에서 만들어 놓은 이벤트 네트워크를 연결할 때는 핸들러 등록 절차AddListner가 있습니다.

아래 예시는 reactive-banana 프레임워크의 코드입니다.

newtype AddHandler a = AddHandler { register :: Handler a -> IO (IO ()) } 
                                                            -- 안 쪽에 또 IO가 있는 이유는
                                                            -- 핸들러 제거용 함수를 돌려주기 때문
newAddHandler :: IO (AddHandler a, Handler a)
do
  (addHandler, fire) <- newAddHandler
  register addHandler putStrLn
  fire "Hello"

newAddHandler는 함수를 두 개 만들어 반환합니다. 하나는 핸들러를 등록하는 함수고, 다른 하나는 이벤트를 시작하게 하는 트리거 함수입니다.

fromAddHandler :: AddHandler a -> MomentIO (Behavior a)

이벤트 네트워크가 동작을 시작actuated하면, 이 함수로 콜백을 등록합니다. 언제든, 이 콜백을 부르면 이벤트 네트워크가 보고 있는 이벤트가 발생합니다.

아마도 다음 소스가 궁금함을 해결하는 포인트가 아닐까 합니다.

newAddHandler :: IO (AddHandler a, Handler a)
newAddHandler = do
    handlers <- newIORef Map.empty -- 핸들러를 기억할 메모리
    let register handler = do -- (1) AddHandler용 함수 만들기
            key <- Data.Unique.newUnique
            atomicModifyIORef_ handlers $ Map.insert key handler -- 핸들러 목록에 추가
            return $ atomicModifyIORef_ handlers $ Map.delete key
        runHandlers a = -- (2) 트리거용 함수 만들기
            runAll a =<< readIORef handlers
    return (AddHandler register, runHandlers)

추상적으로 얘기하면, 핸들러를 등록하는 Map을 만들어 놓고, 여기다 넣는 함수, 여기에 있는 것들 실행하는 함수를 만들어 반환합니다. 등록이 있으니 제거도 있어야 합니다. register 함수의 반환값이 제거용 함수입니다.

외부 이벤트와 연결하는 건 똑같이 AddListner하는 절차가 있습니다. 처음 설명을 보면 FRP는 마치 이런 절차없이 Functional하게 해결하는 것처럼 보이지만, Functional하게 해결하는 부분은 외부 이벤트가 발생한 후 돌아가는 로직에 관한 얘기입니다.

Reactive-banana는 딱히 연속 시간에 대한 개념을 녹여낸 것처럼 보이진 않습니다. 확실치 않으나, 연속 시간에 대한 모델링(시간 변화에 따른 미분, 적분같은 작업)을 하려면 Yampa가 적합하다고 합니다.

이벤트 발생을 어떻게 눈치 채지?

폴 후닥 교수의 first principle에서 발췌

type Behavior a = [Time] -> [a]
type Event a = [Time] -> [Maybe a]

until :: Behavior a -> Event (Behavior a) -> Behavior a
fb `until` fe = \ts ->
  loop ts (fe ts) (fb ts)
  where
    loop ts@(_:ts') ~(e:es) (b:bs) =
      b : case e of
            Nothing -> loop ts' es bs
            Just fb' -> tail (fb' ts)

B = B1 until E1(B2)라 하면, E1이 일어나기 전까진 BB1, E1이 일어나면 BB2란 말입니다.
[Time](_:ts')으로 버려지니 [(),(),(),...] 쯤으로 봐도 될테고,
Behavior a는 이런 [Time]을 받으면 [a,a,a,...]를 반환하는 함수고,
Event a는 이런 [Time]을 받으며 [Nothing, Just a, Nothing,...]을 반환하는 함수입니다.

loop [(),(),()....] [Nothing, Just a, Nothing, ...] [a,a,a,...]

마치 세상 일이 이미 정해져 있는 것처럼, 혹은 모두 과거의 일로 생각한다면, 이벤트는 [Nothing, Nothing, Just a, Nothing, Nothing, ...] 이런식의 리스트로 표현 가능할 겁니다. 리스트에서 다음 원소로 가는 걸, 다음 프레임으로 넘어가는 것으로 해석합니다. 세 번째 프레임에서 Event가 발생했습니다. B Behavior 는 B1 = [x1,x2,x3,...]에서 하나씩 꺼내오다가 Event가 발생하면, 그 후론 B2 = [y1,y2,y3,y4,y5,...]에서 꺼내옵니다. 그럼 B의 값은 [x1,x2,x3,y4,y5,...]가 됩니다. 나중에 복잡하게 Behavior와 Event가 체이닝 되어도, 프레임에 따라서 B의 값은 x_값에서 y_값으로 변한 게 반영됩니다.

그런데, 이 건 이미 정해진 일에 대해서만 가능한 표현 방법 아닌가 싶어, 생각이 막힙니다.

말장난 같은, 사기? 같은 아이디어로, 모두 정해져 있지만, 우리가 알지는 못하는 상황이라 생각해 봤습니다. 버튼 클릭이라는 이벤트를, 프레임마다 원소를 하나씩 내어주는 함수라고 생각하겠습니다. 함수 안에선 무슨 일이 일어나는지 모릅니다. 함수 안에서 실제 세상의 버튼 클릭과 연결해 두는 겁니다. 꺼내가는 사람은 정해진 값을 꺼내간다 생각해도 달라질 게 없습니다.

다른 값을 주기 위해 함수를 변형하는 게 아닙니다. 다른 함수를 가져오면 됩니다.

나중에 러너(실행기? 해석기?)가 until로 만들어진 Behavior를 가져다 [Time]을 넣어주면, 최종 [a]Lazy하게 얻게 될 겁니다.

무엇이 궁금한지 명확해졌습니다.

Event는 함수인데, 버튼을 눌린 시점에 값을 요구하면 Just a를, 그 외의 시간에 요구하면 Nothing을 돌려주는 함수를 어떻게 만들 수 있는가?

Q. 그냥 의존 관계는 t -> a 꼴이 아니더라도, 이벤트 전파 네트워크는 구현된다. 굳이 시간에 대한 의존이 있도록, t ->를 해주는 이유가 뭘까?
A. 아직 정해지지 않은 값을 의미합니다. t가 정해져야 a가 정해진다는 말입니다.

Q. 그런데, 이벤트에 따라 바뀔 세상, 즉 미래 t에 어떤 값이 있을지는 결정이 나지 않은 게 아닌가?
A. 이론상 t -> a일뿐 실 구현은, 이들이 Effectful한 IORef, MVar 등을 읽어 오는 작업이거나, 함수 자체를 스트림 함수(circuit에서 돌아가는 함수)로 정의해서, 함수 자체가 한 번 실행되면, 다음 실행할 함수로 바뀌게 한다든지 하는 테크닉을 쓰는 것으로 보입니다.

t의 쓰임을 쫓아가 봤습니다. 아래 내용은 프레임워크를 쓰기 위해 꼭 알아야 되는 내용은 아닙니다. 시간이 어떤 역할을 하는지 보고싶은 호기심에 뜯어 봤습니다.

아래는 reactive-banana의 reactimate 구현입니다.

newtype Event a = E { unE :: Prim.Event a }
type Event a    = Cached Moment (Pulse a) -- Prim.Event



reactimate :: Event (IO ()) -> MomentIO ()
reactimate = MIO . Prim.addReactimate . Prim.mapE return . unE

addReactimate e = do
    network   <- ask
    liftBuild $ Prim.buildLater $ do
        -- Run cached computation later to allow more recursion with `Moment`
        p <- runReaderT (runCached e) network
        Prim.addHandler p id

data Cached m a = Cached (m a)
runCached :: Cached m a -> m a
runCached (Cached x) = x

newtype AddHandler a = AddHandler { register :: Handler a -> IO (IO ()) }

Moment는 이론상은 tTime으로, Time ->를 붙여 줬었는데, 실제 구현은 EventNetwork { actuated, size, s }를 받는 Reader 모나드입니다.

newtype ReaderWriterIOT r w m a = ReaderWriterIOT { run :: r -> IORef w -> m a }
type Build = ReaderWriterIOT BuildR BuildW IO 
             -- 시간을 받아 작업을 한 후 IORef에 뭔가를 넣는 함수, 아래 (가)를 의미한다.
newtype BuildW = BuildW (DependencyChanges, [Output], Action, Maybe (Build ()))
                      --                                      late 빌드 액션
                      --                              late IO 액션
                      --                    네트워크에 추가될 출력
                      -- 네트워크 토폴로지를 변형하는 액션

-- reader가 쓸 BuildR, writer가 쓸 BuildW

buildLater :: Build () -> Build ()
buildLater x = RW.tell $ BuildW (mempty, mempty, mempty, Just [x)](x).md)

아래는 reactive-banana의 compile 구현입니다.
시간 t에 의존하는 이벤트 네트워크를 IO 액션으로 만들고 있습니다.

-- | 컴파일해서 이벤트 네트워크를 만든다.
compile :: Moment () -> IO EventNetwork
compile setup = do
    actuated <- newIORef False -- 현재 running상태를 나타내는 플래그
    s        <- newEmptyMVar -- setup 콜백 함수 덩어리
    size     <- newIORef 0

    let eventNetwork = EventNetwork{ actuated, s, size }

    (_output, s0) <- -- 그래프 초기화
        Prim.compile (runReaderT setup eventNetwork) =<< Prim.emptyNetwork
        --            ^^^^^^^^^^^^(가)^^^^^^^^^^^^^^
        -- emptyNetwork가 초기화된 네트워크를 만들어서 넘겨 주면, 
        -- 여기에 "할 일 덩어리"를 섞어서 이벤트 네트워크를 만든다.
        -- 이 네트워크 안에 시간 `T`가 들어 있다.
        -- 이 후 Evaluation step할 때마다 `T`를 1 증가 시킨다.
    putMVar s s0 -- 초기 상태 저장
    writeIORef size =<< Prim.getSize s0

    return eventNetwork

-- 아래는 Prim.compile
compile :: BuildIO a -> Network -> IO (a, Network)
compile m Network{nTime, nOutputs, nAlwaysP, nGraphGC} = do
    (a, dependencyChanges, os) <- runBuildIO (nTime, nAlwaysP) m

    applyDependencyChanges dependencyChanges nGraphGC
    let state2 = Network
            { nTime    = next nTime -- 여기서 시간 `T`를 1 증가 시킨다.
            , nOutputs = OB.inserts nOutputs os
            , nAlwaysP
            , nGraphGC
            }
    return (a,state2)

setup으로 들어오는 값은, “외부 이벤트와 연관 지어 놓은 할 일 덩어리”쯤입니다.

type BuildIO = Build
type BuildR = (Time, Pulse ())
--          현재 시각, 

runBuildIO :: BuildR -> BuildIO a -> IO (a, DependencyChanges, [Output])
runBuildIO i m = do
    (a, buildW) <- unfold mempty m -- BuildW (topologyUpdates, os, liftIOLaters, _)) <- unfold mempty m
    bwLateIO buildW -- execute late IOs
    return (a, bwDependencyChanges buildW, bwOutputs buildW)
  where
    -- Recursively execute the  buildLater  calls.
    unfold :: BuildW -> BuildIO a -> IO (a, BuildW)
    unfold w m = do
        (a, buildW) <- RW.runReaderWriterIOT m i -- 여기서 i를 써먹고 있다.
                         -- m은 이벤트 네트워크.
                         -- m이 가진 함수에 시간을 넣어 주고,
                         -- IORef에 무언가를 읽어내거나 담아 두는 작업 
        let w' = w <> buildW{ bwLateBuild = Nothing }
        w'' <- case bwLateBuild buildW of
            Just m  -> snd <$> unfold w' m
            Nothing -> return w'
        return (a,w'')

ReaderWriterIOTReader/Writer 모나드 트랜스포머인데, WriterIORef를 씁니다. 타입이 가진 인자로 값을 유지하지 않고, IORef로 유지한다는 건 다른 곳에서도 참고하는 값일 듯 합니다.

runReaderWriterIOT :: (MonadIO m, Monoid w) => ReaderWriterIOT r w m a -> r -> m (a,w)
runReaderWriterIOT m r = do
    ref <- liftIO $ newIORef mempty
    a   <- run m r ref -- 실행 결과는 a에 바인딩하고, writer모나드가 ref에 뭔가를 담습니다.
    w   <- liftIO $ readIORef ref
    return (a,w)

정리

지금까지 확실한 건,

이벤트와 상태를 Event, Behavior 라는 일등급 요소로 모델링한 후 여러 컴비네이터를 이용해 이벤트가 전파되도록 만드는 게 FRP입니다. 일등급 시민이니 함수에 인자로 주고, 결과로 받는, 즉 평범한 값처럼 처리하는 코드 모양이 나오는 장점이 있습니다. 이 때 제대로 설계하려면 샘플링 단위 시간에 의존하지 않고, 연속된 시간을 기반으로 최대한 로직을 끌고 나갈 수 있게 해야 합니다.

그리고, 또 하나, 코널 교수는 Denotaional하게 (수학적 의미를 고려하며) 설계하는 것도 FRP가 가져야 할 요소로 얘기하는데, 실무를 위해 보고 있는지라, 아직 이 것까지는 깊이 보지 않고 있습니다.

이론에선 시작 시점부터 모든 Event를 기억하고, 모든 Behavior의 변화를 기억한다는데, 실용적으로 이 걸 어찌 쓸까도 고민입니다. 마치 무한 리스트에서 take로 일부 가져오는 게 의미가 있듯, 이들도 이론상은 시작 시점부터 모든 걸 기록하지만, 실제적으론 최근 것만 쓰는 것 아닐까 싶은데요. 무한인 것들은, 만일 전부가 필요할 땐 실용적으론 값의 의미가 있을 수 없으니까요.

RP와는 큰 틀에서 같은 목표를 가진 건 맞지만, 세부적인 구현으로 보면 FRP는 RP와는 무관한 스타일이 아닌가 하는 생각도 듭니다.


  1. evolving value: 시간에 따라 변하는 값을 evolving value라고 말하고 있습니다. 진화evolve하는 값인데, 진화에는 뭔가 더 나아지고, 복잡해지는 뉘앙스가 있는데, 영어는 시간이 지나면서 변화한다는 뜻만 있는 걸까요? 단순 변화를 evolving이라 하면 안 어울리긴 합니다만, 어쨌든 시간 의존 변화를 뜻합니다.↩︎

  2. 1급 값(first class value): 정의할 수 있고, 연결combine할 수 있고, 함수의 입, 출력으로 쓰일 수 있는 대상에 1급이란 말을 붙입니다. 종종 first class citizen이란 말을 쓰는데, 추측으론 영어권에선 차별하지 않는 다는 뜻으로 1급 시민이란 말을 종종 쓰는 것 같습니다. 차별이 되는 대상을 2급이라 표현하기도 합니다. 하스켈에선 보통의 타입과 다를바 없다는 뜻으로 쓰입니다.↩︎

Github 계정이 없는 분은 메일로 보내주세요. lionhairdino at gmail.com