모두를 위한 하스켈/초보자를 위한 하스켈 프로그램 자세한 안내
이 문서의 라이선스는 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
이 임시로 설치된 셸을 쓸 수 있습니다. 이 셸에서는 하스켈 라이브러리 text
와 safe
도 설치됩니다. 하스켈 패키지를 변경할 때는 명령줄을 편집해서 셸을 새로 만들면 됩니다.
새 창에 다음과 같이 입력해서 실시간 타입체킹을 합니다.
$ 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
같은 타입이 문자열 리터럴을 쓸 수 있게 해줍니다.
하스켈의 좋은 점 중 하나는 하스켈이 코드 순서를 신경쓰지 않는다는 것입니다. 코드를 순서에 상관 없이 정의해도 컴파일러가 신경쓰지 않습니다. 그래서 말하자면 코드를 다음과 같이 쓸 수 있습니다. “prefixLength
는 prefix
의 길이(length
)이다. breakOn
으로 문자열을 나눠서 prefix
와 suffix
를 구할 수 있다.”
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 ]
여러 줄 들여쓰기
[+/-]모두 합치기
[+/-]결론
[+/-]작지만 실용적인 프로그램을 만들었던 이런 과정이 하스켈 언어를 배우는데 도움이 되면 좋겠습니다. 하스켈은 여러 좋은 기능과 다양한 개념을 선사합니다. 이 글에서 소개한 것은 빙산의 일각일 뿐입니다.