Sat Sep 30 12:37:49 AM KST 2023 작성 중… 아직 완전히 이해하지 못하고, 일부는 추측하며 정리하고 있습니다.
Haskell/Arrow tutorial - wikibooks
Arrow 실 사용 예시를, 자세한 설명과 함께 볼 수 있는 글 입니다. 처음엔 언제 Arrow를 사용하면 좋을 지 알기 위해 봤는데, 재귀 패턴, 모양을 더 공부하게 만든 예시입니다. Arrow가 어떻게 쓰이는 지만 보기엔 적당한 글이 아닐 수도 있습니다. 우아한 Arrow로 넘어가기 전에 일단 Circuit에서 막히니까요. 여기 글은 위 링크에 있는 튜토리얼을 그대로 가져와 읽으며, 메모를 추가한 정도의 글입니다. 아래에 나오는 코드는 모두 위 링크에서 발췌했습니다.
어느 정도 쓰고 돌아보니, 길게도 썼습니다. 오류가 있을지도 모르는 긴 글을 누가 읽을까 싶은데요. 위 텍스트로 Arrow를 공부하며 벽에 부딪히는 분들을 위한 아이디어가 몇 개 있긴 합니다. 정답을 알려주는 텍스트가 아니라, 같이 공부하는 옆 친구의 노트입니다.
※ 추가적인 메모없이 깨끗한 번역만 보시려면, codeonwort님의 애로우 튜토리얼 - WikibooksHaskell 번역이 공개되어 있습니다.
반복을 표현하는 방법
A.
(+10)
을 for
, 재귀
등으로 10
번 반복해
B.
data AddTen a = AddTen (AddTen a)
즉 AddTen (AddTen (AddTen ...))
무한이 가능한 재귀 타입을 만든다.
패턴 매칭으로 AddTen
을 벗겨 낼 때마다 (+10)
을 하도록 정의한다.
패턴 매칭을 10
번 한다.
(+10)
을 한 번 더하고 싶으면, A 경우엔 그냥 (+10)
을 한 번 더하면 되고,
B 경우엔 한 번 패턴 매칭하면 됩니다.
그런데, AddTen
타입은 리스트 정의처럼 종료([] 혹은 Empty
)를 표시하는 엣지 케이스가 없습니다. AddTen
은 AddTen
값이 있어야 되는데, 처음 AddTen
값은 어떻게 정의할까요? 그리고 정의가 되었다 해도, 무한하게 가버리는 걸 실용에서 어찌 써먹을까요?
A보다 B가 훨씬 더 복잡하고, 머리를 괴롭히는데 B로 하면 A로 하는 것 보다 어떤 장점이 있을까요?
newtype Circuit a b = Circuit {unCircuit :: a -> (Circuit a b, b)}
^^^^^^^^^^^
continuation
순환하는 타입 읽기가 불편하지 않나요? 저는 아직도 편하지 않습니다.
Circuit
타입을 생성하려면 생성자 Circuit
에 (\a -> ???)
같은 함수를 넣어야 하는데, ???
부분에는 뭐가 들어가면 될까요? Circuit
타입을 넣어 줘야 하는데, 그러려면 안 쪽에 또 Circuit
타입이 있고, 그냥 대체하면서 따라가면 무한으로 가버립니다. 타입에서는 이렇게 써 준다고 무한 재귀하는 모양이 아닙니다. 안에 있는 Circuit
타입 값이, 꼭 현재 정의하고 있는 Circuit
과 같은 값일 필요는 없으니, 재귀가 아닌 경우도 있을 수 있습니다. 물론, 값을 만들 때 재귀하는 모양의 값을 만들 순 있습니다. 하지만, 혼자서 무한히 도는 일은 일어나지 않습니다.
data List a = a : List a | []
[]
, 1:[]
, 1:2:[]
… 등이 모두 List
타입의 값이 될 수 있다는 얘기지, 재귀 함수를 읽듯 List
가 무한히 호출되지는 않습니다. 그런데, List
의 경우는 에지 케이스, 즉 []
라는 표현이 있어, 더 이상 이어지는 값이 없을 경우 []
으로 마감하면 됩니다. 하지만 Circuit
의 경우는 Sum타입으로 에지 케이스를 가지고 있지 않습니다. Circuit
은 종료를 표현할 수 없으니 무한 반복으로 갈 것만 같습니다.
하스켈은 Lazy합니다. 타입 생성자로 쌓여 있는 건 패턴 매칭으로 벗겨내거나, unCircuit
같은 함수를 실행하기 전에는 안에 들어있는 값을 볼 수 없습니다. Circuit
안에 들어 있는 Circuit
은 또 풀려고 하지 말고, 그냥 안을 보지 않은 채로 Circuit
으로만 봐야 합니다. 안을 들여다 볼 필요가 있기 전 까지, 안은 안보면 됩니다.
여기까진 그런대로 쫓아 올만한데, 그럼 아무 Circuit
타입 값이 없는 제일 처음에 Circuit
타입의 값을 만들려면(값 생성자로 생성하려면) 어떻게 할까요?
let cir = Circuit (\a -> (???,b))
cir
에 넣어 줄, 알고 있는 Circuit
타입 값이 없습니다?!
아닙니다. 적어도 하나가 있습니다. 바로 정의되고 있는 자기 자신입니다.
(아래 쪽에 보면, id
도 있긴 합니다. 다른 Circuit
값을 못 쓰진 않지만, 텍스트에선 “처음 생성”에는 모두 순환하는 예시만 보입니다. )
let cir =
이라고만 해도, 쓸 수 있는 cir
이 하나 생겼습니다. ???
자리엔 Circuit
을 넣어줘야 하니, cir
을 넣어줘도 됩니다. Lazy하니 cir
이 무슨 값인지는 필요할 때, 또 보면 됩니다. ※ 컴파일러가 바인딩하는 과정(WHNF 개념 포함)을 보면, 이런 재귀적인 표현들이 “약간” 더 편해지긴 합니다.
let cir = Circuit (\a -> (cir, b))
타입 정의 자체가 순환 정의는 아니지만, 위와 같이 값을 선언하면 순환하는 값을 가지게 됩니다.
※ cir
값을 위와 같이 만들어 뒀다면, let cir2 = Circuit (\a -> (cir, b))
로 cir2
정의할 때 cir
을 가져다 쓸 수 있습니다. 텍스트를 보면, 시작부터 다른 Circuit
을 가지게 하진 않고, 컴비네이션을 하는 동안, 두 Circuit
을 합친다든지 하며 다른 Circuit
이 들어갑니다.
합성을 추상화 시킨 인터페이스는 Category
클래스에 있습니다.
class Category cat where
id :: cat a a
(.) :: cat b c -> cat a b -> cat a c
instance Cat.Category Circuit where
.) = dot
(where
Circuit cir2) `dot` (Circuit cir1) = Circuit $ \a ->
(let (cir1', b) = cir1 a
= cir2 b
(cir2', c) in (cir2' `dot` cir1', c)
Circuit
두 개를 모두 거쳐 결과값c
를 만들고, 또 언제든 두 개를 모두 거칠 수 있도록 dot
으로 합성한 걸 가지고 있습니다. dot
을 구현하는데, 안에 또 dot
이 나옵니다. Circuit
안에 Circuit
이 또 들어 있으니, 당연히 또 나오겠거니 예측할 수 있긴 합니다.
Circuit
은 a
를 받고, 결과값을 갖고 있는 2튜플을 돌려주는 함수입니다. Circuit
을 잠시 가리고 보면,
-> ( , b) a
텍스트에선 b
를 내부 상태값internal state이라 표현하고 있습니다. a
를 받아 b
상태가 되고, b
를 받아 c
상태가 되게 하고, 여전히 Circuit
안에 들어 있게 하면 2튜플의 두 번째 자리에 상태를 유지할 수 있습니다. 저는 정보량으로 추상화 시켜서 보곤 합니다. a -> b
와 달리 a -> (??, b)
는 분명히 추가 정보를 가지고 있습니다. 이 함수 자체만으로는 추가 정보가 어떤 역할을 하지 못하지만, ??
을 꺼내가는 다른 절차가 끼어들어 다른 역할을 할 수 있을 거라 추측해 볼 수 있습니다.
마지막 (cir2'
dot
cir1')
해석이 어렵습니다.
실제 코드가 아니라 눈에 잘 보이게, c1
과 c2
의 합성을 c1-c2
로 쓰고, 합성 결과에 있는 ...(c2' dot c1',...)
를 c1'-c2'
라고 쓰겠습니다. 한 번돌고나서 새로 만들어지는 Circuit
은 프라임'
을 붙이겠습니다.
이 무한히 쌓여갈 것만 같은 dot
의 반복은 어떻게 해석할까요?
in
을 빼고 let
부분만 보면, 첫 번째 Circuit
를 돌리고 나온 결과를, 두 번째 Circuit
에 입력으로 넣어주고 있습니다. 텍스트에서 보면 c1
이 안에 가지고 있는 c1'
은 둘이 같거나, c1
과 인자를 달리해서 만들어냅니다.(텍스트에선 결과에 의존해서 새 Circuit
을 만듭니다.) c1-c2
를 돌린 다음에, 또 돌린다면 c1'-c2'
을 돌립니다. 그 다음에 돌린다면 c1''-c2''
을 돌릴 겁니다. Circuit
들은 입출력으로 서로 이어지고 있지만, 다음 돌릴 자신의 “후임” Circuit
은 각자 알아서 독립적으로 만들어 냅니다.
만일 5개의 Circuit
을 합성했다면, 순서대로 입출력을 넘겨 받으며 c1-c2-c3-c4-c5
를 실행하고, 다음 번에도 c1'-c2'-c3'-c4'-c5'
를 돌릴거란 뜻으로 Circuit $ \a -> (c1'-c2'-c3'-c4'-c5', 다섯 개 Circuit을 연이어 돈 결과)
를 반환합니다. runCircuit
이 “리스트에 남은 원소가 있다면”, 준비한 c1'-c2'-c3'-c4'-c5'
를 돌리고, 다음 돌릴 Circuit
은 c1''-c2''-c3''-c4''-c5''
으로 준비 할 겁니다. 만일, runCircuit
하기 전에 c6
을 합성한다면 (c1-c2-c3-c4-c5-c6, 여섯 개 Circuit을 연이어 돈 결과)
를 준비합니다.
제 생각을 쓰는 것도 어려우니, 읽는 분들에게 전달이 잘 될리가 없겠습니만, 그래도, 최선을 다해 불편함을 주는 부분을 짚어보면,
Circuit
은 독립적으로 다음 Circuit
을 준비합니다. 처음 Circuit
에서 변하지 않을 수도 있고, 변했을 수도 있습니다.Circuit
은 각자 독립적으로 준비한 다음, 이 들을 모아서 다음 돌릴 준비를 합니다.전체적으로 불편함을 주는 이유는, 여기선 무한 반복으로 갈 것만 같은 코드 모양이지만, runCircuit이 리스트에 원소가 남아 있는 동안만 반복 시킵니다. 무한 리스트를 정의하고, 필요한 만큼만 take
하는 것과 비슷합니다. 하스켈에서 만나는 (실용적인) 무한한 것들은 대부분 필요한 만큼만 꺼내올 줄 아는 파트너가 존재합니다. 언제 멈출 수 있는지, 언제 다음 반복을 시작하는지를 알게 되면 조금 덜 불편합니다.
이제 위 (.)
을 만나 아무 일도 일어나지 않는 id
를 정의합니다.
instance Cat.Category Circuit where
id = Circuit $ \a -> (Cat.id, a)
Q.
Circuit
값이 들어갈 자리에Cat.id
가 들어가 있다.cat a a
타입이니 컴파일러가Circuit a a
로 추론할 수는 있겠지만, 디폴트 구현이 있거나 하진 않다. 실제 구현은 없는 상태인데, 이래도 되는가?
A. 추측)Cat.id
를Circuit a a
로 추론할텐데, 그럼 현재 정의하고 있는id
를 재귀적으로 가지고 오면 됩니다.
(.)
구현에 넣어 보겠습니다. 두 번째 인자로 주면
Circuit cir2) `dot` (Circuit $ \a -> (Cat.id, a)) = Circuit $ \init ->
(let (cir1', b) = (\a -> (Cat.id, a) init)
-- = (Cat.id, init)
= cir2 b
(cir2', c) -- init
in (cir2' `dot` Cat.id, c)
-- c는 init에 Circuit을 한 번 적용한 것 과 같다.
첫 번째 인자로 주면
Circuit $ \a -> (Cat.id, a)) `dot` (Circuit cir1) = Circuit $ \init ->
(let (cir1', b) = cir1 init
= (\a -> (Cat.id, a) b)
(cir2', c) -- = (Cat.id, b)
in (Cat.id `dot` cir1', c)
-- c는 init에 Circuit을 한 번 적용한 것 과 같다.
최종 결과에 여전히 남아 있는 Cat.id
가 어찌될까 보기에 불편하긴 합니다. 그래도 정보량에서 다른 건 아닌가 하고요. 나중에 runCircuit
으로 돌릴 때를 생각하면
Cat.id
dot
Circuit cir1
과
Circuit cir1
dot
Cat.id
는
Circuit cir1
과 같은 결과값을 가집니다.
instance Arrow Circuit where
= Circuit $ \a -> (arr f, f a)
arr f Circuit cir) = Circuit $ (\(b, d) ->
first (let (cir', c) = cir b
in (first cir', (c, d)) -- d는 손 대지 않는다.
Arrow는 합성할 함수를 래핑합니다. 여기선 입력 -> ( 다음에 쓸 Circuit , 현재 결과 )
를 래핑합니다.
arr
은 보통의 하스켈 함수를 Arrow로 리프팅할 때 씁니다.
타입으로 래핑해서 컴비네이션을 하는 것들은, 보통 run~
과정을 거쳐 결과값을 얻습니다. 패턴 매칭이나 unCircuit
같은 과정을 통해 래핑된 타입의 바깥 세계와 만나게 됩니다.
runCircuit :: Circuit a b -> [a] -> [b]
= []
runCircuit _ [] :xs) =
runCircuit cir (xlet (cir', x') = unCircuit cir x
in x' : runCircuit cir' xs
자기 자신을 가지고 있는 Circuit
의 경우는 cir'
과 cir
이 같아져서 이런 패턴의 코드가 필요 없어도 될 것만 같지만, 다른 Circuit
을 가지고 있을 수도 있기 때문에, runCircuit
은 위와 같이 cir
이 돈 후에, cir'
이 돌도록 정의해햐 합니다. 무한 Circuit
을 끊어주는 작업은 []
패턴매칭이 담당합니다.
accum :: acc -> (a -> acc -> (b, acc)) -> Circuit a b
= Circuit $ \input ->
accum acc f let (output, acc') = input `f` acc
in (accum acc' f, output)
f input acc
작업을 Circuit
으로 한 번 래핑한 덕분에, acc
를 유지 및 유통할 수 있습니다.
Q.
Circuit
이 들어갈 자리에accum acc' f
가 들어갔다?
A.accum
의 반환 타입은Circuit a b
입니다.
Q.
accum
이 무한 반복되는 건가?
A.acc
,f
만 줘서accum
을 실행하면, 안에 있는accum
이 바로 불리지 않습니다. 안에 있는accum
이 불리려면 추가 절차가 있어야 합니다.Circuit
으로 패턴 매칭을 해서\input -> ... (accum ...)
함수를 꺼내고,input
값을 줘서 실행해야 합니다. 그렇게 실행하면 또 다시Circuit
값에 머물러 있고, 또 언젠가 패턴 매칭하면 그 때 다음 동작이 일어날 겁니다. 정리하면, 그냥은 반복되지 않고, 반복 절차로 들어가기 위한 추가 조건이 있는 셈입니다.
accum
같은 함수를 읽을 때, 다음처럼 읽으면 도움이 됩니다.
accum
은 acc
와 f
를 받아서 Circuit
을 만들어 내는 함수입니다. 현재는 acc
, f
를 가지고 작업했지만, 다음 작업은 acc'
, f
를 가지고 작업할 겁니다. acc
가 acc'
으로 바뀌었습니다.
accum' :: b -> (a -> b -> b) -> Circuit a b
= accum acc (\a b -> let b' = a `f` b in (b', b')) accum' acc f
accum
이 두 번째 인자로 받는 함수는 a -> acc -> (b, acc)
2튜플을 반환하는 함수를 받습니다. accum'
는 반환값이 2튜플이 아닌 a -> b -> b
함수를 받아, 결과를 복사해서 2튜플로 만드는 과정을 붙여 accum
에게 넘기는 보조 함수입니다.
total :: Num a => Circuit a a
= accum' 0 (+) total
위 작업은 다음과 같습니다.
= accum 0 (결과값 복사해서 2튜플 만들기 . (+)) total
이제 위에 그냥 무한 반복이 아니라, 반복하려면 절차가 필요하다고 말했던, 그 절차를 runCircuit
으로 아래와 같이 실행할 수 있습니다.
GHCi> runCircuit total [1,0,1,0,0,2] [1,1,2,2,2,4]
runCircuit
은 Circuit
타입과 리스트를 넘겨주면, 리스트 맨 앞 원소에 Circuit
이 갖고 있는 작업을 진행해서 바꿔 놓고, 이어서 그 다음 원소에는 Circuit
이 안에 갖고 있는 후임 Circuit
을 적용합니다. 보통, 자기 자신과 같은 Circuit
을 적용하거나, 조금 다른 인자로 만들어진 Circuit
를 적용합니다.
Q. 자신과 같은
Circuit
을 계속 부르는데, 어떻게 값이 누적되고 있을까?
A.accum
의 반환 값 안에 있는...(accum acc' f, ...)
에서accum
은 함수고,acc
와f
를 줘야만Circuit
이 됩니다. 현재Circuit
에 있는 함수를 적용해서acc'
을 만들고, 이 값으로accum
을 호출해서 다음 적용할Circuit
을 만듭니다. 완전 동일한Circuit
을 매 번 적용하고 있는 건 아닙니다.
proc
notationmean1 :: Fractional a => Circuit a a
= (total &&& (const 1 ^>> total)) >>> arr (uncurry (/)) mean1
평균을 내려면 전체 합과 원소 수가 필요합니다.
여기선, 컴비네이터들이 하는 일만 간단히 보면,
(&&&)
는 Arrow 두 개를 받아, 각각의 계산computation을 실행하고, 결과를 2튜플로 만듭니다.
(>>>)
는 Arrow 두 개를 합성합니다.
(^>>)
는 const 1 ^>> total
= arr (const 1) >>> total
입니다.
※ 참고 - Arrow는 모나드의 일반화
mean1
을 해석해 보면, 나중에 runCircuit
으로 리스트가 들어오면,
total
을 돌려 누적값으로 바꾸고,
const 1 ^>> total
을 돌려, 들어온 값을 1
로 바꾸고 total
을 돌려 누적값으로 바꿉니다.
결국 리스트의 가장 마지막 누적값이 원소 개수가 됩니다.
uncurry
가 없다면 [(모든 원소를 더한 값, 원소 개수)]
이 됩니다. C1 &&& C2
에서 C1
은 C1
대로 후임 Circuit
을 만들고, C2
는 C2
대로 후임 Circuit
을 만듭니다.
GHCi> runCircuit (total &&& (const 1 ^>> total)) $ [0,10,7,8] [(0,1),(10,2),(17,3),(25,4)]
mean2 :: Fractional a => Circuit a a
= proc value -> do -- value에 0 10 7 8 순서대로 들어가면
mean2 <- total -< value -- 0 10 17 25
t <- total -< 1 -- 1 2 3 4
n return -< t / n -- 한 번 돌면 0/1 : 두 번째 10/2 : 세 번째 17/3 : 네 번째 25/4
※ 참고 - 확장 Arrows
mean1
, mean2
의 실행 결과는
GHCi> runCircuit mean1 [0,10,7,8]
[0.0,5.0,5.666666666666667,6.25]
GHCi> runCircuit mean2 [0,10,7,8] [0.0,5.0,5.666666666666667,6.25]
total
이 Arrow이기 때문에 (&&&)
을 써서 병행하는 작업을 쉽게 표현할 수 있습니다.
행맨 게임: 낱말 맞추기. 알파벳 글자 수 만큼 빈자리를 만들어 둡니다. 돌아가며 한 글자를 골라서 낱말 안에 있으면 해당 자리에 보여주고, 없으면 페널티 포인트를 올리는 게임입니다.
위에서 정의한 Circuit
을 이용해 게임 흐름을 모두 Arrow로 만드는 방법을 보여 줍니다.
제일 먼저 사전에서 랜덤하게 문제를 뽑습니다.
generator :: Random a => (a, a) -> StdGen -> Circuit () a
range rng = accum rng $ \() rng -> randomR range rng generator
accum
은 나중에 runCircuit
을 통해 입력을 받아 \() rng -> ...
함수를 실행한 후, 결과값을 가진 상태로 또 다음 accum
실행을 “준비”하는 함수입니다. 한 가지만 빼 놓고, 나머지 필요한 것들은 모두 미리 준비해 둔 작업입니다 .
= ["dog", "cat", "bird"]
dictionary
pickWord :: StdGen -> Circuit () String
= proc () -> do -- 나중에 runCircuit으로 rng에 랜덤gen을 넘깁니다.
pickWord rng <- generator (0, length dictionary - 1) rng -< ()
idx -< dictionary !! idx returnA
여기선 특별한 외부 입력값을 받는 건 아니고, runCircuit
으로 더미로 ()
유닛을 받을 겁니다. 유닛으로 채워진 리스트를 받으면, gen
값이 바뀌는 걸 잃어버리지 않고 다음으로 넘겨가며 유닛 개수만큼 랜덤값을 뽑아냅니다.
※ Reactive로 가면 이런 모양을 자주 만납니다. 시간의 흐름을 ()
무한 리스트로 두고, 한 스텝(샘플링) 나아갈 때마다 ()
를 가져오는 것으로 모델링합니다.
Circuit
으로 래핑되어 있기 때문에, 안 쪽에 있는 함수에게 값을 주어 실행한다든지, 결과를 바깥으로 내보내려면, 매 번 패턴 매칭을 해서 풀어야 합니다. 하지만, Arrow 인스턴스로 만들었기 때문에, <-
,-<
등을 이용해 쉽게 표현할 수 있습니다.
GHCi> rng <- getStdGen
GHCi> runCircuit (pickWord rng) [(), (), ()] ["dog","bird","dog"]
oneShot :: Circuit () Bool
= accum True $ \_ acc -> (acc, False)
oneShot -- False로 하드 코딩
당장은 이게 뭐하는 Circuit
인가 싶은데 일단, 처음만 True
를 돌려주고 그 후로는 False
를 돌려준다는 것만 보고 넘어가 가겠습니다. (나중에 게임 시작에 딱 한 번만 일어나야 하는 동작을 표현할 때 씁니다.)
GHCi> runCircuit oneShot [(), (), (), (), ()] [True,False,False,False,False]
delayedEcho :: a -> Circuit a a
= accum acc (\a b -> (b, a))
delayedEcho acc -- 누적값과 결과값을 스왑
누적값과 결과값을 바꿔치기 합니다. 이 것도 뭐에 쓰는 물건인가 싶습니다.
GHCi> runCircuit (delayedEcho False) [True, False, False, False, True] [False,True,False,False,False]
게임의 메인 Arrow는 계속 반복해서 실행되고, 낱말은 처음 한 번만 뽑고, 게임 진행동안 계속 기억하고 있어야 합니다. Rather than just mask its output on subsequent loops (이 말은 아마도, 실제 뽑는 작업은 하면서, 쓰지 않는 걸 얘기하는 것 같습니다.) 실제 한 번만 돌아가는게 퍼포먼스에서 유리합니다. 하지만, Circuit에선 “Arrow 뭉치combination”에 포함된 Arrow 컴포넌트의 모든 경로로 데이터가 흘러갑니다. (골드 버그 장치 혹은 도미노 등이 연상됩니다.) 하나의 경로로만 흘러가게 하려면, ArrowChoice
클래스의 인스턴스로 만들어야 합니다.
※ 뭉치란 말이 투박하긴 하지만, 컴비네이션 결과물을 지칭하기에 적당한 의미를 가진 듯 합니다. 다른 곳에서도 쓰이는 “용어”는 아닙니다. 순수 한글 말을 쓰려고 고른 게 아니라, 대상을 가장 잘 표현하는 적당한 말같아 골랐습니다.
instance ArrowChoice Circuit where
@(Circuit cir) = Circuit $ \ebd -> case ebd of
left origLeft b -> let (cir', c) = cir b
in (left cir', Left c)
Right d -> (left orig, Right d)
left
에 Circuit
을 넣으면, Either
를 받는 Circuit
으로 바뀝니다. 입력으로 Left
값이 들어 오면, 현재 Circuit
이 가지고 있는 작업을 하고, Right
가 들어 오면, 아무 작업도 하지 않습니다. 역시 마지막 라인의 (left orig, Right d)
가 목에 걸립니다. Either
를 받도록 바뀐 이 Circuit
은, 다음 번 반복에도 Either
를 받습니다. 위에서 설명한 (.)
을 다시 떠올리면, c1-c2
Circuit
을 돌렸다면, 다음 번엔 c1'-c2'
을 돌리게 됩니다. 반복하려 할 때, 매 번 처음부터 다시 Arrow 뭉치를 만드는 게 아닙니다. 이전 반복에서 나온 결과들을 가지고 있는 상태입니다.
※ 하스켈은 다른 언어의 런타임들이 해주는 일을, runCircuit
같은 걸로 사용자 수준으로 당겨온 느낌입니다.
ArrrowChoice
인스턴스로 만들면, <- 다음에 if를 쓸 수 있어, 이제 어떤 Arrow를 실행할지 고를 수 있습니다. 여기서 쓰인 if
는 하스켈에서 보통 쓰던 그 if
가 아닙니다.
<-
와 -<
사이에는 proc
의 인자를 포함해서 어떤 local name binding도 들어갈 수 없습니다.
-> do
proc rng <- generator (0, length dictionary -1) rng -< () --X 이렇게 못씀
idx -- proc의 인자 rng를 <-, -< 사이에 써줄 수 없다.
-< dictionary !! idx returnA
generator (0, length disctionary -1)
을 평가 할 때는 rng
가 스코프에 없습니다. 시작할 때만 Arrow가 생성되기 때문에 그렇습니다. 만일, 매 번 실행될 때마다 생성된다면 Arrow가 안에 상태state를 유지할 수 없을 겁니다.
getWord :: StdGen -> Circuit () String
= proc () -> do
getWord rng <- oneShot -< () -- 처음 시작에만 True고, 그 뒤로는 모두 False
firstTime <- if firstTime
mPicked then do
<- pickWord rng -< () -- 처음 시작이면 단어 선택
pciked -< Just picked
returnA else
-< Nothing -- 처음 시작이 아니라면 Nothing
returnA <- accum' Nothing mplus -< mPicked
mWord -- Nothing `mplus` (Just "dog") = Just "dog"
-- (Just "cat") `mplus` (Just "dog") = Just "cat"
-< fromJust mWord
returnA -- fromJust는 Nothing이면 error를 던진다.
getWord
를 실행해 보면,
GHCi> rng <- getStdGen
GHCi> runCircuit (getWord rng) [(), (), (), (), (), ()] ["dog","dog","dog","dog","dog","dog"]
여러 개의 Circuit
들을 붙여 Circuit
뭉치를 만들고, 이 뭉치를 runCircuit
으로 반복해서 돌릴텐데, 이렇게 도는 동안 getWord
는 몇 번을 돌려도 처음 선택한 값만 가지고 있습니다.
게임중에 반복되어 실행될 코드를 보면
attempts :: Int
= 5
attempts
livesLeft :: Int -> String
= "Lives: ["
livesLeft hung ++ replicate (attempts - hung) '#'
++ replicate hung ' '
++ "]"
hangman :: StdGen -> Circuit String (Bool, [String]) ------- Circuit 타입!
= proc userInput -> do
hangman rng <- getWord rng -< () -- 문제를 랜덤하게 고르고,
word let letter = listToMaybe userInput -- 나중에 runCircuit이 넣어 준다.
<- updateGuess -< (word, letter)
guessed <- updateHung -< (word, letter)
hung <- delayedEcho True -< not (word == guessed || hung >= attempts)
end let result = if word == guessed
then [guessed, "You won!"]
else if hung >= attempts
then [guessed, livesLeft hung, "You died!"]
else [guessed, livesLeft hung]
-< (end, result)
returnA where
updateGuess :: Circuit (String, Maybe Char) String ------- Circuit 타입!
= accum' (repeat '_') $ \(word, letter) guess ->
updateGuess case letter of
Just l -> map (\(w, g) -> if w == l then w else g) (zip word guess)
Nothing -> take (length word) guess
updateHung :: Circuit (String, Maybe Char) Int ------- Circuit 타입!
= proc (word, letter) -> do
updateHung -< case letter of
total Just l -> if l `elem` word then 0 else 1
Nothing -> 0
main :: IO ()
= do
main <- getStdGen
rng interact $ unlines
. ("Welcome to Arrow Hangman":)
. concat . map snd . takeWhile fst
. runCircuit (hangman rng)
. ("":)
. lines
hangman
이란 커다란 Circuit
뭉치를 만들고, runCircuit
이 입력이 “한 번” 들어올 때마다 돌립니다. (합성 설명할 때처럼 바뀌었다는 의미에서 프라임을 붙이겠습니다.) 처음엔 Circuit
을 돌리고, 그 다음 번엔 Circuit'
을 돌리고, 그 다음엔 Circuit''
… 이런 식입니다. Circuit
이 돌고 나서 (continuation으로) 후임 Circuit'
을 준비하는데, 기억해야 하는 정보를 모두 집어 넣어 준비합니다.
전역 변수 같은 바깥 스코프의 mutable한 변수-기억 장소-를 둘 수 없기 때문에 필요한 테크닉입니다. 지금 동작하는 함수 뭉치가, 결과값과 함께 다음 번 돌릴 함수continuation를 준비하면서 기억해야 될 것들을 새로 만드는 함수에 실어 놓습니다. 그리고는 run
Circuit 같은 것들이 run
하면서 준비된 함수를 실행합니다.
※ Reactive(Reactive-banana, Yampa, Reflex)를 볼 때 비슷한 모양들을 만나게 되니, 이런 continuation을 가진 모양에 충분히 익숙해지면 도움이 됩니다. 쓰고 보니, 함수형 전반에 걸쳐서 계속 만나는 모양입니다.
Welcome to Arrow Hangman
___
Lives: [#####]
a
___
Lives: [#### ]
g
__g
Lives: [#### ]
d
d_g
Lives: [#### ]
o
dog You won!
interact
함수는 전체 stdin 입력을 받아 String -> String
함수에 넘깁니다.
lines
는 String
을 받아 [String]
리스트로 만듭니다.
runCircuit
은 Circuit
과 리스트를 받으면 리스트를 출력합니다.
interact
가 받은 함수는, 한 번 돌아서 작업을 마친 후에, 또 반복하며 도는게 아닙니다. String
을 받아 한 번만 돌고 String
을 출력할 뿐입니다. 중간에, 원소 하나에 대한 작업이 끝나면 볼 수 있게 해놨을 뿐, interact
가 String -> String
함수를 loop 돌리는 게 아닙니다. 실행시킨 후 특별히 Ctrl-c 등으로 stdin을 끌내지 않으면, 입력으로 들어가는 String
이 무한히 들어갑니다. Enter
키를 누를 때마다 lines
가 출력할 리스트의 원소 하나가 준비됩니다. 그럼 리스트를 받는 runCircuit
이 전체는 아직이라도, 원소 하나가 준비되는대로 가져옵니다. 원소 하나를 받아 Circuit
을 돌리고, 후임 Circuit
을 준비한 후 다음 원소를 Lazy하게 기다립니다.
스트림(여기선 무한 리스트지만, 어떤 것이든 재귀로 정의된 타입)을 다루는 Lazy 표현은 우아하지만, Strict 나라에서 살던 사람이, Lazy 나라로 넘어와서, 읽는 것을 넘어 원어민처럼 생각 자체를 Lazy하게 하려면 벽이 심하게 높은 것 같습니다.
GHC에는 arrow
구문과, arrow
에 적용하는 함수를 컴바인하는 바나나 브라켓 (| |)
가 있습니다. 이를 이용하면 다음처럼 쓸 수 있습니다.
mean3 :: Fractional a => Circuit a a
= proc value -> do
mean3 <- (| (&&&) (total -< value) (total -< 1) |)
(t, n) -< t / n returnA
mean2 :: Fractional a => Circuit a a
= proc value -> do
mean2 <- total -< value
t <- total -< 1
n -< t / n returnA
Arrow
를 인자로 받아 동작하는 함수와 Arrow
구문을 묶을 때 바나나 브라켓을 쓴다고 합니다. (Ross Paterson 논문에선 괄호 대신 form
키워드를 썼다고 합니다.) 바나나 브라켓 안의 첫 번째 아이템은 임의의 개수의 Arrow
를 입력으로 받아 Arrow
를 돌려주는 함수입니다. 중위 연산자는 이 안에서는 쓸 수 없습니다.
mean3 :: Fractional a => Circuit a a
= proc value -> do
mean3 <- (| (&&&) (total -< value) (total -< 1) |)
(t, n) ^^^^^ ^^^^^^^^^^^^^^^^ ^^^^^^^^^^^^
| Arrow Arrow
Arrow를 받는 함수
-< t / n returnA
“Although there’s no reason to” 라고 하는 걸로 보아, 잘 안 쓰이나 봅니다. proc
구문을 쓰지 않고 편하게 Arrow
를 묶을combine 수 있는 게 장점인데,
mean4 :: Fractional a => Circuit a a
= proc value -> do
mean4 <- (total -< value) &&& (total -< 1)
(t, n) return -< t / n
이렇게, 바나나 브라켓을 떼어내도 GHC가 알아서 잘 해석한다니, 가볍게 보고 지나가도 될 것 같습니다.
이 글은, 어떤 요소가 Arrow로 해결하면 좋은가를 찾는 게 목표입니다.
먼저 Circuit
을 왜 Arrow 인스턴스로 만들었을까요?
Circuit
타입은 뭘 의미할까요?= Circuit (\a -> ( eachTimeAddOne, (+1) a))
eachTimeAddOne -- 원래 작업은 \a -> (+1) a
\a -> a + 1
을 Circuit
타입에 넣으면 무슨 일을 할 수 있을까요?
함수형은 모든 필요한 값은 인자로 받아야만 하고, 전역 변수같이 메모리를 따로 둘 수 없어 체이닝동안 생기는 기억해야 할 값들을 편리하게 다루기 위한 테크닉들이 필요합니다.
(지금 부터는 아주 상상입니다.)
\a -> a + 1
을 Circuit
으로 래핑해 두면, 반드시 Circuit
을 패턴 매칭해서 안을 봐야 a + 1
을 꺼내 올 수 있으니, 반드시 일어나야 하는 절차를 둘 곳이 생깁니다. 타입으로 래핑하니, 같이 따라 다녀야 할 정보를 둘 자리도 튜플같은 Product 타입으로 마련할 수 있습니다.
여러 정보를 한 곳에 묶어두는 방법으로 함수 안에다 두는 방법이 있습니다. 체이닝동안 필요한 정보를 product 타입으로 둘 수도 있지만, 함수가 가지고 있게 할 수도 있습니다. 동적인 정보라면 이렇게 하는 쪽이 더 적합할 수 있습니다.
보통 함수 체이닝을 할 때 (.)
을 써서 체이닝을 합니다. 하스켈에서 이루어지는 체이닝(컴비네이션)들은 자세히 보면, 타입이 가지고 있는 함수를 체이닝 하거나, 혹은 함수 자체를 체이닝 하는 것처럼 말하지만, 사실은 컴비네이터를 체이닝하는 것입니다. 예를 들어 a -> b
, b -> c
, c -> d
를 체이닝한다면, (.) (b -> c) (a -> b)
와 (c -> d)
를 다시 (.)
으로 묶습니다. (.)
의 체인입니다. 체이닝할때마다 반복되는 작업이 있다면 (.)
을 새롭게 정의하면 됩니다. 모나드 같은 경우는 Effect 관련 작업은 바인드가 맡아서 하도록 해 뒀습니다. 모나드 경우도 a -> m b
의 체인이 아니라, 바인드의 체인입니다.
코드 구석 구석을 읽다보면, 이 부분을 간과하는 경우가 많은 것 같습니다. 타입, 컴비네이터 공조만으로 끝난 게 아니라, 한 가지가 더 있습니다. 바로 트리거를 당기는 역할을 하는 run~
류의 함수들입니다. 우리말로는 “실행기runner”라 번역하기도 합니다. Circuit
타입을 정의하고, 합성하거나 흐름을 제어하는 컴비네이터들을 정의해서 커다란 Circuit
뭉치를 만들었다면, 마지막 runCircuit
을 거치게 됩니다.
입력 a를 받아서 b를 얻기까지 보통 함수와 Circuit의 차이
a -> b vs Circuit 타입
각 종 컴비네이터 runCircuit
절차를 이리 많이 추가해 뒀으니, 훨씬 많은 걸 표현할 수 있는 건 당연한데, 명령형에 머리가 고정되어 있어, 가끔 퍼포먼스가 염려되긴 합니다.
이제 위 시각으로 Circuit
을 읽어 보겠습니다.
언젠가 a
를 받으면 b
를 돌려주는 게 아니라,
언젠가 a
를 받으면, (후임 Circuit - 다음 이어지는 작업에 할 일, 결과 b)
2튜플을 결과로 돌려줄 “준비”를 한 함수를 돌려줍니다. 후임 Circuit은 결과 b에 의존해서 만들거나 현재 Circuit과 같을 수 있습니다. 그럼, runCircuit
이 처음 입력값 a
를 가지고, 리스트의 첫 번째 원소에 작업을 하고, 그 다음 원소를 꺼내 후임 Circuit
을 꺼내와 사용합니다. 이 후임 Circuit
안에는 또 이어서 쓸 후임 Circuit
이 들어 있습니다.
계속 반복되는 메인 루프가 있고, 루프가 도는 동안 기억해야 할 정보들이 있습니다. 아주 일반적인 상황 아닌가 싶은데요. 행맨 정도의 게임에서는 Circuit
으로 모델링하는 게 우아해 보이기는 한데, 루프가 복잡해져도 우아할 수 있을까 싶습니다.
루프 안을 뜯어보면, 데이터 흐름을 요리 조리 바꿔야 합니다. 병행 작업을 하는 것도 있고, 상황에 따라 분기(if
)해야 하는 것도 있고요. 이럴 때 Arrow
인터페이스를 도입하면, 병행, if
를 다루는 인터페이스도 있고, 무엇보다 do
노테이션으로 보기 좋게 표현할 수 있다는데 눈이 갑니다.
위 정리는 너무 일반적인 상황이라, 이 게 Arrow가 쓰이는 이유라면, 엄청나게 많은 경우에 Arrow를 쓸텐데, 그러지 않는 걸 보면 모델링 대상이 복잡해지면 우아하지 않기 때문 아닐까요?
합성 (.)
을 일반화 하기 위해 타입으로 래핑한다는 생각은 어렵지 않지만, 이렇게 래핑한 것들의 흐름을 제어할 수 있는 체계들을 정돈되게 만들어 둔 건 쉽게 생각할 수 있는 건 아니겠습니다.
참고
Arrow를 이론적으로 접근하면 Freyd Category로 접근해야 하는데, 그나마 실용에 좀 가까운 “이론”으로 접근하려면
Bartosz Milewski - Arrows are strong profunctor 이 출발점이 될 수 있겠습니다.