트랙
/
Elm
Elm
/
연습 문제
/
Paola의 명성 높은 피자
Paola의 명성 높은 피자

Paola의 명성 높은 피자

학습 연습 문제

소개

파싱

파싱은 텍스트를 의미 있는 데이터로 변환하는 과정이에요. String 패키지의 함수들은 그런 면에서 한계가 있고 정규 표현식은 사용하기 어려워서, Elm에서 파싱하는 관용적인 방법은 elm/parser 패키지를 쓰는 거예요.

프로그래밍 언어 LOLCODE의 (일부분)을 파싱하는 파서를 작성하는 예제를 함께 살펴봐요. 다음과 같이 주석이 달린 프로그램을 봐요:

HAI 1                 # program version 1
CAN HAS STDIO?        # import stdio
VISIBLE "HAI WORLD!"  # print "HAI WORLD!"
KTHXBYE               # end of program

먼저, 가능한 명령을 나타내는 Elm 타입을 작성해봐요:

type Command
    = Version Int
    | Import String
    | Print String

그러면 LOLCODE 프로그램은 List Command로 모델링할 수 있어요. HAI 1 줄을 위한 파서부터 시작해봐요:

versionP : Parser Command
versionP =
    Parser.succeed Version
        |. Parser.keyword "HAI"
        |. Parser.spaces
        |= Parser.int

versionP는 _파서 파이프라인_을 사용해서 만들어요. (|.) 연산자는 "문자열을 파싱하지만 결과는 버린다"는 뜻이고 (|=)는 "문자열을 파싱하고 결과는 유지한다"는 뜻이에요. 이 예제에서 Parser.keyword "HAI"는 "HAI"를 소비하지만 그 결과는 무시돼요. Parser.spaces는 ' ', '\n', '\r' 문자를 여러 개 소비하지만, 이것도 무시돼요. Parser.int는 정수를 파싱하고, 그 정수의 값(예를 들어 1)이 맨 위의 Parser.succeed Version에 전달되어 파서는 Version 1이라는 값을 성공적으로 반환해요.

파이프라인에 있는 파서 중 하나라도 실패하면 Parser.DeadEnd를 반환하고 전체 파서가 실패해요. 파서는 Parser.run으로 실행할 수 있어요:

Parser.run versionP "HAI 1"
    --> Ok (Version 1)
Parser.run versionP "HAI 2"
    --> Ok (Version 2)
Parser.run versionP "BYE 2"
    --> Err [{ problem = ExpectingKeyword "HAI", col = 1, row = 1 }]
Parser.run versionP "HAI MOM"
    --> Err [{ problem = ExpectingInt, col = 5, row = 1 }]

이제 "CAN HAS STDIO?" 줄을 다음으로 처리해봐요.

importP : Parser Command
importP =
    Parser.succeed Import
        |. Parser.keyword "CAN HAS"
        |. Parser.spaces
        |= packageP
        |. Parser.symbol "?"

importP도 파서 파이프라인이지만, 이번에는 "?" 같은 기호를 파싱하는 데 적합한 Parser.symbol로 끝나요. 우리가 유지하고 싶은 값(임포트할 패키지 이름)은 다음으로 파싱해요.

packageP : Parser String
packageP =
    Parser.chompWhile Char.isAlphaNum
        |> Parser.getChompedString
        |> Parser.map String.toLower

패키지 이름이 뭘지 모르기 때문에 Parser.keyword를 쓸 수 없어요. 대신 조건을 만족하는 한(여기서는 영숫자 문자인 경우) 문자를 하나씩 소비하는 Parser.chompWhile을 사용해요. 이렇게 소비한 문자들은 Parser String을 반환하는 Parser.getChompedString으로 모여요. 이 경우 패키지 이름을 정규화하고 싶어서, Parser.map을 사용해 파서에 담긴 문자열을 수정해 소문자로 만들어요.

Parser.run importP "CAN HAS STDIO?"
    --> Ok (Import "stdio")
Parser.run importP "CAN HAS StDiO?"
    --> Ok (Import "stdio")
Parser.run importP "CAN HAS STDIO"
    -->  Err [{ problem = ExpectingSymbol "?", col = 14, row = 1 }]

다음으로, VISIBLE "HAI WORLD!"는 다음으로 파싱해요.

printP : Parser Command
printP =
    Parser.succeed Print
        |. Parser.keyword "VISIBLE"
        |. Parser.spaces
        |. Parser.symbol "\""
        |= stringP
        |. Parser.symbol "\""

stringP : Parser String
stringP =
    Parser.chompWhile (\c -> c /= '"')
        |> Parser.getChompedString

Parser.run printP "VISIBLE \"HAI WORLD!\""
    --> Ok (Print "HAI WORLD!")

이제 각 명령에 대한 파서가 있어요. 물론 일반적인 프로그램에서는 이 명령들의 순서를 예측할 수 없어서, 범용 명령 파서를 만들어요.

commandP : Parser Command
commandP =
    Parser.oneOf
        [ versionP
        , importP
        , printP
        ]

Parser.oneOf는 주어진 순서대로 파서를 하나씩 시도해요. 파서가 실패하면 다음 것을 시도하지만, 성공하면 나머지 파서를 시도하지 않고 그 결과를 반환해요. 모든 파서가 실패하면 Parser.oneOf도 실패해요.

Parser.run commandP "HAI 2"
    --> Ok (Version 2)
Parser.run commandP "CAN HAS STDIO?"
    --> Ok (Import "stdio")
Parser.run commandP "VISIBLE \"HAI WORLD!\""
    --> Ok (Print "HAI WORLD!")
Parser.run commandP "O NOES"
    --> Err [ { problem = ExpectingKeyword "HAI", col = 1, row = 1 }
    --      , { problem = ExpectingKeyword "CAN HAS", col = 1, row = 1 }
    --      , { problem = ExpectingKeyword "VISIBLE", col = 1, row = 1 }
    --      ]

실패한 시도마다 Parser.oneOf가 모으는 Parser.DeadEnd를 만들어낸다는 점에 주목해요.

전체 프로그램을 파싱하려면 여러 명령을 순차적으로 파싱해야 하는데, 다음과 같이 해요.

programP : Parser (List Command)
programP =
    Parser.sequence
        { start = ""
        , separator = "\n"
        , end = "KTHXBYE"
        , spaces = Parser.succeed ()
        , item = commandP
        , trailing = Parser.Optional
        }

Parser.sequence는 여러 옵션을 가진, 아주 유연하게 설정할 수 있는 파서예요. start, separator, end는 시퀀스의 항목을 구분하는 문자열을 받는데, Elm 리스트를 파싱할 때는 "[", ",", "]"를 써요. spaces는 항목과 구분자 사이의 잠재적인 공백을 처리할 파서를 받아 유연성을 제공하는데, Elm 리스트를 파싱할 때는 Parser.spaces를 쓰지만 이 경우에는 공백을 허용하지 않으므로 아무것도 실제로 소비하지 않고 바로 성공하는 Parser.succeed ()를 써요. item은 항목 파서를 받고, 마지막으로 trailing은 뒤따르는 구분자가 Parser.Forbidden, Parser.Mandatory, Parser.Optional 중 무엇인지 정할 수 있게 해줘요.

Parser.run programP "HAI 2\nCAN HAS STDIO?\nKTHXBYE"
    --> Ok [Version 2, Import "stdio"]
Parser.run programP "KTHXBYE"
    --> Ok []
Parser.run programP "HAI 2\nCAN HAS STDIO?\n"
    --> Err [ { problem = ExpectingKeyword "HAI", col = 1, row = 3 }
    --      , { problem = ExpectingKeyword "CAN HAS", col = 1, row = 3 }
    --      , { problem = ExpectingKeyword "VISIBLE", col = 1, row = 3 }
    --      , { problem = Expecting "KTHXBYE", col = 1, row = 3 }
    --      ]

파서가 성공하면 문자열 소비를 멈춰요.

Parser.run Parser.int "1"
    --> Ok 1
Parser.run Parser.int "1 BTW VISIBLE \"U SEE NOTHING\""
    --> Ok 1

그래서 문자열의 모든 것을 파싱했는지 확인해야 한다면 Parser.end를 사용할 수 있어요.

fullProgramP : Parser (List Command)
fullProgramP =
    programP
        |. Parser.spaces
        |. Parser.end

Parser.run fullProgramP "HAI 2\nCAN HAS STDIO?\nKTHXBYE"
    --> Ok [Version 2, Import "stdio"]
Parser.run fullProgramP "HAI 2\nCAN HAS STDIO?\nKTHXBYE\nBTW VISIBLE \"U SEE NOTHING\""
    --> Err [{ problem = ExpectingEnd, col = 1, row = 4 }]

마지막으로, 파싱한 내용에 따라 파서를 실패시키는 결정을 내려야 할 때가 있어요. 예를 들어, "HAI" 명령 두 개로 연달아 시작하는 프로그램을 실패시키는 파서가 필요하다면 다음을 쓸 수 있어요.

singleVersionProgramP : Parser (List Command)
singleVersionProgramP =
    fullProgramP
        |> Parser.andThen
            (\commands ->
                case commands of
                    (Version _) :: (Version _) :: _ ->
                        Parser.problem "Multiple versions defined"

                    _ ->
                        Parser.succeed commands
            )

Parser.andThen은 파서의 내용을 살펴보고 그 값에 따라 새로운 파서를 반환해요. 여기서는 한 경우에 Parser.problem으로 커스텀 메시지와 함께 파서를 실패시키고, 다른 경우에는 원래 값을 반환했어요.

Parser.run singleVersionProgramP "HAI 2\nCAN HAS STDIO?\nKTHXBYE"
    --> Ok [Version 2, Import "stdio"]
Parser.run singleVersionProgramP "HAI 1\nHAI 2\nCAN HAS STDIO?\nKTHXBYE"
    --> Err [{ problem = Problem "Multiple versions defined", col = 8, row = 4 }]

지침

여러분은 새로운 레스토랑 매니저로 Paola's Prestigious Pizza에 합류하게 되어 설레요. 이곳을 효율적으로 운영할 계획이 많지만, 그러려면 데이터가 좀 필요해요. 가장 먼저 메뉴를 분석해야 해요.

메뉴에는 여러 종류의 피자가 있어요. Paola가 텍스트 파일로 목록을 줬는데, 한 줄에 피자 하나씩 들어 있어요. 각 피자 항목은 이름, 있을 수도 있는 채식 표시, 가격이라는 세 가지 요소로 이루어져 있어요.

1. 피자 가격 파싱하기

메뉴의 처음 몇 줄을 살펴봐요:

Regina: tomato, ham, mushrooms, cantal - 11€
Formaggio (v): tomato, emmental - 8€
...

가격부터 시작하는 게 좋겠어요. 모든 가격은 유로화의 정수예요.

priceParser를 구현해서 가격을 파싱해요. 모든 가격이 유로이므로 통화를 따로 기억할 필요는 없지만, 그래도 파싱은 해야 해요.

Parser.run priceParser "8€"
    --> Ok 8
Parser.run priceParser "8"
    --> Err [{ problem = ExpectingSymbol "€", col = 2, row = 1 }]

2. 채식 표시 파싱하기

메뉴를 다시 살펴봐요:

Regina: tomato, ham, mushrooms, cantal - 11€
Formaggio (v): tomato, emmental - 8€
...

채식 표시 "(v)"도 꽤 간단해서, 다음으로 이걸 하기로 해요!

vegetarianParser를 구현해서 표시를 파싱해요. 파서는 Bool을 반환해야 하는데, 표시가 있으면 True, 없으면 False예요.

Parser.run vegetarianParser "(v)"
    --> Ok True
Parser.run vegetarianParser ""
    --> Ok False

3. 피자와 재료 이름 파싱하기

자, 그다음은 뭘까요?

Regina: tomato, ham, mushrooms, cantal - 11€
Formaggio (v): tomato, emmental - 8€
...

재료와 피자 이름은 꽤 단순해 보여요. 모두 대문자와 소문자 ASCII 문자로 이루어진 한 단어예요.

wordParser를 구현해서 이름을 파싱해요. 겸사겸사 모든 단어를 통일성을 위해 소문자로 만들어요.

Parser.run wordParser "REGINA"
    --> Ok "regina"
Parser.run wordParser "tomato"
    --> Ok "tomato"
Parser.run wordParser "(v)"
    --> Ok ""
Parser.run wordParser ""
    --> Ok ""

4. 재료 목록 파싱하기

그런데 재료는 여러 개가 한데 모여 있어요:

Regina: tomato, ham, mushrooms, cantal - 11€
Formaggio (v): tomato, emmental - 8€
...

쉼표와 간혹 공백으로 구분된 한 단어들이에요.

ingredientsParser를 구현해서 재료들을 파싱해요.

Parser.run ingredientsParser "tomato, emmental"
    --> Ok ["tomato", "emmental"]
Parser.run ingredientsParser "tomato"
    --> Ok ["tomato"]

5. 피자 전체 파싱하기

이제 재료가 다 모였으니 Pizza를 만들 시간이에요!

Regina: tomato, ham, mushrooms, cantal - 11€
Formaggio (v): tomato, emmental - 8€
...

먼저 피자 이름, 있을 수도 있는 채식 표시, 콜론 ':', 재료 목록, 대시 '-', 그리고 가격이 오는데, 사이사이에 공백이 있을 수 있어요.

pizzaParser를 구현해서 피자 전체를 파싱해요.

Parser.run pizzaParser "Regina: tomato, ham, mushrooms, cantal - 11€"
    --> Ok (Pizza "regina" False ["tomato", "ham", "mushrooms", "cantal"] 11)

6. 메뉴 전체 파싱하기

파티할 시간이에요!

menuParser를 구현해서 텍스트 파일에서 줄바꿈 문자 '\n'로 구분된 모든 피자 목록을 파싱해요. 파서를 파일 끝에 도달할 때까지 실행해서 피자를 모두 가져왔는지 확인해요.

Parser.run menuParser "Regina: tomato, ham, mushrooms, cantal - 11€\nFormaggio (v): tomato, emmental - 8€"
    --> Ok [Pizza "regina" False ["tomato", "ham", "mushrooms", "cantal"] 11,
    --      Pizza "formaggio" True ["tomato", "emmental"] 8]
Parser.run menuParser "Regina: tomato, ham, mushrooms, cantal - 11€\[END]"
    --> Err [{ problem = ExpectingEnd, col = 1, row = 2 }]

7. 여러 단어로 된 재료 이름 파싱하기

이런, 뭔가를 놓친 것 같아요. 메뉴 아래쪽 어딘가에, 한 단어가 아닌 재료들이 있어요:

...
Tonno: tomato sauce, tuna - 10€
Hawaii: tomato sauce, fresh pineapple, ham - 9€
...

oneIngredientParser를 구현하는데, 대문자와 소문자 ASCII 문자 또는 공백 ' '을 받아들여야 해요. 겸사겸사 wordParser에 있던 작은 결함도 고쳐서, 빈 문자열이 유효한 재료로 인식되지 않게 하고 대신 Problem "empty string"을 내보내요. 그러는 김에 문자열을 소문자로 만들고 양쪽의 공백 문자도 제거해요.

Parser.run oneIngredientParser "Tomato Sauce"
    --> Ok "tomato sauce"
Parser.run oneIngredientParser "   tomato sauce     "
    --> Ok "tomato sauce"
Parser.run oneIngredientParser ""
    --> Err [{ problem = Problem "empty string", col = 1, row = 1 }]

파서를 정의했으면 ingredientsParser에서 사용해요.

GitHub에서 편집 링크가 새 창이나 탭에서 열려요
Elm Exercism

Paola의 명성 높은 피자 문제를 시작해 볼 준비가 됐나요?

Exercism에 가입하고 Elm 트랙을 개념 28개연습 문제 110개, 그리고 실제 사람의 멘토링과 함께 배우고 익혀 보세요. 모두 무료예요.