Elm 같은 순수 함수형 언어에서는 같은 인자로 호출한 함수가 항상 같은 값을 반환해야 해요.
따라서 rand : Int라는 타입 시그니처를 가진 함수는 rand = 4로밖에 구현할 수 없는데, 이는 난수를 생성하는 데에 그다지 도움이 되지 않아요.
그럼 Elm에서는 어떻게 난수를 생성할까요?
문제를 둘로 나눠요. 먼저 Random.Generator a로 생성하려는 값을 표현하고, 그다음에 값을 실제로 생성해요.
값을 생성하는 첫 번째 방법은 initialSeed : Int -> Seed로 Random.Seed를 만들고, 그다음 step : Generator a -> Seed -> ( a, Seed )를 사용하는 거예요. step은 값과 새로운 시드를 반환해요.
이 두 함수는 모두 순수하기 때문에, 같은 인자로 두 번 호출하면 같은 값이 나와요.
값을 생성하는 두 번째 방법은 generate : (a -> msg) -> Generator a -> Cmd msg를 사용하는 건데, 이는 Elm 애플리케이션 안에서만 할 수 있어요.
이 경우 Elm 런타임은 step뿐만 아니라 외부의 순수하지 않은 자원도 사용해서 시드를 생성할 수 있어요.
이제부터는 제너레이터에 집중해 볼게요.
예제를 보여주기 위해, initialSeed와 step으로 제너레이터에서 여러 값을 생성하는 함수 generate : Int -> Generator a -> List a를 정의했다고 가정해 볼게요.
Random 모듈은 특정 범위의 정수와 실수를 생성하는 두 가지 기본 제너레이터를 제공해요.
generate 5 (Random.int -5 5)
--> [0, 3, -5, 5, 0]
generate 3 (Random.float 0 5)
--> [1.61803, 3.14159, 2.71828]
이 값들은 튜플로, 또는 값의 배열로 묶을 수 있어요.
generate 2 (Random.list 3 (Random.int 0 3))
--> [[0, 3, 3], [1, 3, 2]]
generate 2 (Random.pair (Random.int 0 3) (Random.float 10 10.3))
--> [(0, 10.23412), (2, 10.17094)]
elm-community/random-extra 패키지는 문자열, 날짜, 딕셔너리, 배열, 집합 등 다양한 자료 구조를 위한 훨씬 많은 제너레이터를 제공해요.
Random.constant를 사용하면 항상 하나의 값만 반환하는 제너레이터를 만들 수 있어요.
generate 4 (Random.constant "hello")
--> ["hello", "hello", "hello", "hello"]
Random.uniform을 사용하면 주어진 원소들 중에서 같은 확률로 무작위로 하나를 고를 수 있어요.
generate 5 (Random.uniform Red [Green, Blue])
--> [Red, Blue, Blue, Green, Red]
Random.uniform은 (Red와 [Green, Blue]) 두 개의 인자를 받아서 고를 값이 적어도 하나는 있도록 보장해요. 배열 하나는 비어 있을 수도 있으니까요.
Random.weighted를 사용하면 확률을 조정할 수도 있어요.
generate 5 (Random.weighted (80, Red) [(15, Green), (5, Blue)])
--> [Red, Red, Green, Red, Red]
값들의 합이 100이 될 필요는 없어요. 어차피 다시 정규화되니까요.
Random.map으로 제너레이터 내부에 접근할 수 있어요.
generate 3 (Random.int 1 6 |> Random.map (\n -> n * 10))
--> [30, 60, 10]
Random.map2부터 Random.map5까지 사용해서 더 많은 제너레이터를 조합할 수도 있어요.
position =
Random.map3
(\x y z -> Position x y z)
(Random.float -100 100)
(Random.float -100 100)
(Random.float -100 100)
generate 1 position
--> [Position 33.788 -98.321 10.0845]
더 복잡한 용도로는 Random.andThen : (a -> Generator b) -> Generator a -> Generator b가 있어요. 이는 한 제너레이터가 생성한 값을 사용해서 다른 제너레이터를 만들 수 있어요.
bool = Random.uniform True [False]
failHalfOfTheTime : Generator a -> Generator (Maybe a)
failHalfOfTheTime generator =
bool
|> Random.andThen
(\boolResult ->
if boolResult then
Random.map Just generator
else
Random.constant Nothing
)
generate 6 (Random.int 0 1 |> failHalfOfTheTime)
--> [Nothing, Just 1, Just 0, Nothing, Just 1, Nothing]
제너레이터를 재귀적으로 정의하는 것이 유용할 때도 있어요.
그럴 때는 컴파일러가 제너레이터를 무한히 펼치는 것을 막기 위해 Random.lazy를 사용해야 할 수도 있어요.
type Peano
= Zero
| Next Peano
peano : Generator Peano
peano =
Random.uniform (Random.constant Zero)
[ Random.map Next (Random.lazy (\_ -> peano))
]
|> Random.andThen identity
generate 12 peano
--> [Zero, Next(Next(Zero)), Zero, Next(Zero), Zero, Zero, Next(Zero), Zero, Zero, Next(Zero), Next(Next(Next(Zero)))]
이 예제는 조금 복잡하니까 하나씩 뜯어볼게요.
Peano는 양의 정수를 나타내는 타입이에요. Zero는 0, Next(Zero)는 1, Next(Next(Zero))는 2, 이런 식이죠.
무작위 Peano 숫자를 얻도록 peano를 정의해요.
먼저, Random.lazy는 로직에 아무것도 더하지 않는다는 점을 기억해요. 그저 컴파일러가 peano를 peano 안에 끝없이 써 넣는 것을 막아줄 뿐이에요.
Random.uniform을 사용해서 0(50% 확률)과 다른 Peano 숫자에 1을 더한 값(50% 확률) 중에서 고르죠.
그런데 앞의 예제와 달리, Random.uniform은 (Zero 같은) 값이 아니라 (Random.constant Zero 같은) 제너레이터 중에서 고르고 있어요. peano 자체를 사용하고 싶기 때문이죠.
즉, Random.uniform은 Generator (Generator Peano) 타입의 값을 반환하게 되는데, 이건 우리에게 필요한 게 아니에요.
제너레이터를 "평탄화"하려면, Random.andThen : (a -> Generator b) -> Generator a -> Generator b에 파이프로 연결하는데, 여기서 b는 Peano가 되어야 해요.
Generator a는 Generator (Generator Peano) 타입이에요. 따라서 a는 Generator Peano이고, 함수 (a -> Generator b)는 (Generator Peano -> Generator Peano) 타입이어야 해요.
아무것도 바꾸고 싶지 않으니까 identity가 알맞은 선택이에요.
마지막으로, peano는 어떤 숫자들을 만들어낼까요?
50%의 확률로 0을 만들고, 나머지 50%의 확률로 자기 자신을 한 번 더 적용한 값에 1을 더한 값을 만들어요.
즉, 숫자는 0(50%의 확률), 1(25%의 확률), 2(12%의 확률), 3(6%의 확률) 등이 돼요.
peano는 얼마든지 큰 숫자를 만들어낼 수 있지만, 확률은 기하급수적으로 줄어들어요.
새로운 세대의 던전과 모험 무대를 설계하는 미로 연구원이에요.
이론가라서 미로의 물리적인 배치에는 관심이 없어요. 그건 미로 엔지니어가 할 일이죠. 대신 분기 계수, 최대 깊이 같은 수학적 복잡성에 관심이 있어요.
그래서 다음 프로젝트는 무작위 생성 방식으로 접근해보기로 해요.
좋은 미로는 대부분 막다른 길로 이루어져 있어요.
deadend 제너레이터가 항상 DeadEnd 변형을 생성하도록 정의해요.
모험가들이 미로 속을 탐험하고 싶어질 이유를 만들어 줘요.
treasure 제너레이터가 Gold, Diamond, Friendship 중 하나를 같은 확률로 생성하도록 정의해요.
그다음 room 제너레이터가 treasure가 생성한 값을 Room 변형으로 감싸도록 정의해요.
이제 미로의 심장이자 영혼인 가지를 살펴볼까요.
branch 제너레이터는 Generator Maze 인자를 받아요. 이 인자는 임의의 미로를 생성할 수 있는 제너레이터예요.
branch 제너레이터를 정의해서 Branch 변형을 생성하도록 해봐요. 이 변형은 인자가 생성한 미로로 이어지는 가지 여러 개를 담고 있어요.
가지는 2개에서 4개 사이여야 해요. 그보다 많으면 미로 엔지니어가 화를 낼 거예요. 개수는 같은 확률로 무작위하게 정해져요.
이제 본격적으로 만들어 볼까요.
maze 제너레이터가 60% 확률로 막다른 길을, 15% 확률로 보물 방을, 25% 확률로 가지를 생성하도록 정의해요.
가지는 maze를 재귀적으로 사용해 새로운 미로를 생성해야 해요.
제너레이터 안에서 deadend, room, branch를 꼭 사용해야 해요.
각 가지는 평균적으로 미로 3개를 담고 있어요. 그런데 이 중 다시 가지를 뻗는 것은 25%뿐이라 1보다 작아요. 그래서 생성된 미로는 언젠가는 끝날 가능성이 높아요.
maze도 나쁘지 않지만 몇 가지 문제가 있어요.
MazeMakerSupport.mazeDepth 함수로 계산해요)mazeOfDepth n 제너레이터가 깊이 n인 미로를 생성하도록 정의해요.
가장 깊은 레벨 0만 막다른 길이나 보물 방을 담아야 하고(둘은 같은 확률로 생성돼요), 나머지 레벨은 모두 mazeOfDepth 제너레이터를 재귀적으로 호출해 생성한 깊이 n-1의 미로를 담은 가지여야 해요.
제너레이터 안에서 deadend, room, branch를 꼭 사용해야 해요.
Exercism에 가입하고 Elm 트랙을 개념 28개연습 문제 110개, 그리고 실제 사람의 멘토링과 함께 배우고 익혀 보세요. 모두 무료예요.