모두를 위한 하스켈/초보자를 위한 하스켈 프로그램 자세한 안내

이 문서의 라이선스는 CC-BY 4.0을 따릅니다. 원저자는 가브리엘라입니다. 이 글의 원래 제목은 ⟨Detailed walkthrough for a beginner Haskell program⟩입니다.

이 문서에서는 작은 하스켈 프로그램 개발 과정을 단계별로 설명합니다. 만드는 프로그램은 코드 뭉치를 등호 기준으로 정렬합니다. 이 문서는 초보 프로그래머를 대상으로 합니다. 여러 단계와 개념을 자세히 설명합니다.

이 문서에서는 실험과 학습을 쉽게 하기 위해 파일 하나에 하스켈 프로그램을 작성하고 컴파일하며 실행합니다. 큰 하스켈 프로젝트를 할 때는 cabal이나 stack을 써서 프로젝트를 만들거나 실행하고 다른 사람과 프로젝트를 공유할 수 있습니다. 저는 주로 이런 방식으로 설명합니다. 이렇게 하면 프로그래밍 언어를 가볍게 시작하고 체험해볼 수 있기 때문입니다.

배경

+/-

저는 제가 쓰는 코드 가독성에 집착합니다. 쓰기 편한 것보다는 읽기 편한 것이 좋습니다. 코드 가독성을 높일 수 있는 방법 중 하나는 등호를 기준으로 정렬하는 것입니다. 예를 들어 다음과 같은 코드가 있습니다.

address = "192.168.0.44"
port = 22
hostname = "wind"

저는 보통 등호 기준으로 정렬하기 위해 수동으로 들여쓰기를 합니다.

address  = "192.168.0.44"
port     = 22
hostname = "wind"

저는 텍스트 에디터로 vim을 씁니다. vim에서는 Tabular 플러그인을 설치하면 등호 기준 정렬을 할 수 있습니다. 하지만 직접 바닥부터 구현하는 것이 함수형 스타일로 어떻게 프로그래밍을 하는지 보여주는 좋은 사례가 될 것 같습니다.

vim의 좋은 기능 중 하나는 아무 명령줄 프로그램을 이용해서 에디터 안에서 텍스트를 바꿀 수 있다는 것입니다. 예를 들어 비주얼 모드에서 텍스트를 선택하고 다음과 같이 입력합니다.

:!some-command

위와 같이 입력하면 vim에서 선택한 텍스트가 some-command라는 명령줄 프로그램 인자로 들어갑니다. some-command 프로그램의 표준 출력 결과를 원래 선택했던 텍스트와 바꿉니다.

저는 정렬할 텍스트를 표준 입력으로 받아 정렬된 텍스트를 표준 출력으로 보내는 코드를 작성하기만 하면 됩니다. 이 프로그램 이름을 align-equals라고 하겠습니다.

개발 환경

+/-

명령줄은 제 IDE나 마찬가지입니다. 저는 보통 터미널 창 세 개를 띄웁니다.

  • vim으로 텍스트를 편집하는 창
  • ghcid로 타입 에러를 표시하는 창
  • 내가 입력한 코드를 REPL에서 테스트하는 창

저는 Nix를 사용합니다. 특히 개발 도구를 설정하기 위해 nix-shell을 사용합니다. 저는 제 전역 시스템에 불필요한 프로그램이 쌓이는 게 싫습니다. nix-shell을 사용하면 개발 도구나 라이브러리를 임시로 설정할 수 있습니다.

앞으로 나오는 예제는 다음과 같이 모두 Nix 셸에서 실행합니다.

$ nix-shell --packages 'haskellPackages.ghcWithHoogle (pkgs: [ pkgs.text pkgs.safe ])' haskellPackages.ghcid

터미널에서 위와 같이 입력하면 ghc, ghci, ghcid, hoogle이 임시로 설치된 셸을 쓸 수 있습니다. 이 셸에서는 하스켈 라이브러리 textsafe도 설치됩니다. 하스켈 패키지를 변경할 때는 명령줄을 편집해서 셸을 새로 만들면 됩니다.

새 창에 다음과 같이 입력해서 실시간 타입체킹을 합니다.

$ ghcid --command='ghci align-equals.hs'

위와 같이 입력하면 align-equals.hs 파일 내용이 변경되었을 때 자동으로 ghcid가 재시작됩니다. 하스켈 컴파일러가 에러나 경고를 찾을 경우 ghcid가 알려 줍니다.

두 번째 터미널에서는 편집 중인 코드를 ghci REPL에서 엽니다. ghci에서는 작성한 함수를 인터랙티브하게 테스트할 수 있습니다.

$ ghci align-equals.hs
GHCi, version 8.2.2: http://www.haskell.org/ghc/  :? for help
[1 of 1] Compiling Main             ( test.hs, interpreted )
Ok, one module loaded.
*Main>

세 번째 터미널에서는 실제로 파일을 편집합니다.

$ vi align-equals.hs

프로그램

+/-

먼저 우리가 하려는 일을 말로 설명한 다음 코드로 옮겨 봅시다.

= 기호 앞의 길이가 가장 긴 줄을 찾은 다음 다른 모든 줄의 = 기호 앞에 공백을 추가해서 가장 긴 줄과 길이를 맞출 겁니다.

등호 앞의 길이

+/-

줄을 입력했을 때 = 기호 앞의 글자(프리픽스, prefix)가 모두 몇 개인지 계산하는 함수가 필요합니다. 이 함수는 타입이 다음과 같습니다.

import Data.Text (Text)

prefixLength :: Text -> Int

이 타입은 말로 이렇게 표현할 수 있습니다. “prefixLength는 함수이다. 이 함수에 타입 Text(입력한 줄)를 넣으면 타입 Int(처음으로 나오는 = 기호 앞에 오는 글자 개수)가 나온다.” 다음과 같이 주석을 적어도 됩니다.

prefixLength
  :: Text
  -- ^ 입력한 줄
  -> Int
  -- ^ 처음으로 나오는 = 기호 앞에 오는 글자 개수

위 코드에서 저는 Data.Text를 임포트했습니다. 왜냐하면 하스켈 Prelude에 있는 기본 String 타입은 비효율적이라 선호하지 않기 때문입니다. text 패키지는 String을 대신할, 성능이 좋은 Text라는 이름의 타입을 제공합니다. Text 타입에 유용한 기능도 많이 있습니다.

prefixLength 함수를 다음과 같이 구현할 수 있습니다.

{-# LANGUAGE OverloadedStrings #-}

import Data.Text (Text)

import qualified Data.Text

prefixLength :: Text -> Int
prefixLength line = Data.Text.length prefix
  where
    (prefix, suffix) = Data.Text.breakOn "=" line

함수 이름이 말하듯이 prefixLength= 기호 앞쪽(prefix)의 길이(length)입니다. 이 코드에서 쉽지 않은 부분은 Data.Text.breakOn이라는 함수를 찾아내는 것입니다.

저는 보통 하스켈 패키지 온라인 문서를 탐색할 때 구글에서 “hackage ${패키지 이름}”(예를 들어 여기서는 “hackage text")”라고 검색합니다. breakOn 함수는 이 방법으로 찾은 것입니다.

어떤 사람은 hoogle을 선호합니다. hoogle은 하스켈 함수를 이름이나 타입으로 색인하고 검색할 수 있는 도구입니다. 예를 들어 Text 값을 앞부분과 뒷부분으로 나누는 함수를 찾으려면 다음과 같이 실행해서 결과를 확인합니다.

$ hoogle 'Text -> (Text, Text)'
Data.Text breakOn :: Text -> Text -> (Text, Text)
Data.Text breakOnEnd :: Text -> Text -> (Text, Text)
Data.Text.Lazy breakOn :: Text -> Text -> (Text, Text)
Data.Text.Lazy breakOnEnd :: Text -> Text -> (Text, Text)
Data.Text transpose :: [Text] -> [Text]
Data.Text.Lazy transpose :: [Text] -> [Text]
Data.Text intercalate :: Text -> [Text] -> Text
Data.Text.Lazy intercalate :: Text -> [Text] -> Text
Data.Text splitOn :: Text -> Text -> [Text]
Data.Text.Lazy splitOn :: Text -> Text -> [Text]
-- plus more results not shown, pass --count=20 to see more

우리가 만든 함수가 예상대로 동작하는지 REPL에서 확인할 수 있습니다.

*Main> :reload
Ok, one module loaded.
*Main> :set -XOverloadedStrings
*Main> Data.Text.breakOn "=" "foo = 1"
("foo ","= 1")
*Main> prefixLength "foo = 1"
4

하스켈 Prelude의 기본 String 타입을 쓰지 않고 Text를 썼기 때문에 OverloadedStrings 확장을 켜야 합니다. 이 확장을 켜면 다른 패키지가 Text 같은 타입이 문자열 리터럴을 쓸 수 있게 해줍니다.

하스켈의 좋은 점 중 하나는 하스켈이 코드 순서를 신경쓰지 않는다는 것입니다. 코드를 순서에 상관 없이 정의해도 컴파일러가 신경쓰지 않습니다. 그래서 말하자면 코드를 다음과 같이 쓸 수 있습니다. “prefixLengthprefix의 길이(length)이다. breakOn으로 문자열을 나눠서 prefixsuffix를 구할 수 있다.”

prefixLength line = Data.Text.length prefix
  where
    (prefix, suffix) = Data.Text.breakOn "=" line

이렇게 순서와 상관 없는 코딩 스타일은 느긋한 계산과도 잘 맞습니다. 하스켈은 “느긋한”(lazy) 언어입니다. 프로그램이 순서에 상관 없이 계산을 할 수도 있고 사용되지 않는 코드는 아예 계산하지 않을 수도 있습니다. 예를 들어 우리가 만든 prefixLength 함수는 suffix를 쓰지 않습니다. 따라서 프로그램은 suffix를 계산하거나 메모리에 값을 할당하지 않습니다.

하스켈로 프로그래밍을 하면 할수록 프로그램을 일련의 명령문으로 생각하지 않고 서로 의존하는 계산의 그래프로 생각하게 될 겁니다.

들여쓰기

+/-

이제 프리픽스에 원하는 길이만큼 공백을 추가하는 함수가 필요합니다.

adjustLine :: Int -> Text -> Text

다음과 같이 주석과 함께 적어도 됩니다.

adjustLine
  :: Int
  -- ^ 원하는 프리픽스 길이
  -> Text
  -- ^ 길이를 채워야 할 프리픽스
  -> Text
  -- ^ 길이를 맞춘 프리픽스

이 함수를 다음과 같이 구현할 수 있습니다. 코드 길이가 좀 길지만 내용은 직관적입니다.

adjustLine :: Int -> Text -> Text
adjustLine desiredPrefixLength oldLine = newLine
  where
    (prefix, suffix) = Data.Text.breakOn "=" oldLine

    actualPrefixLength = Data.Text.length prefix

    additionalSpaces = desiredPrefixLength - actualPrefixLength

    spaces = Data.Text.replicate additionalSpaces " "

    newLine = Data.Text.concat [ prefix, spaces, suffix ]

여러 줄 들여쓰기

+/-

모두 합치기

+/-

결론

+/-

작지만 실용적인 프로그램을 만들었던 이런 과정이 하스켈 언어를 배우는데 도움이 되면 좋겠습니다. 하스켈은 여러 좋은 기능과 다양한 개념을 선사합니다. 이 글에서 소개한 것은 빙산의 일각일 뿐입니다.