Real World Haskell 9장 번역Real World Haskell 9장 번역

Posted at 2015.01.16 15:10 | Posted in 지식저장소/읽은 책 요약

어쩌다 번역해 봤는데, 진 빠진다. 더 안할 듯. 원본 링크

9장. 입출력 사례 예제: 파일 시스템 검색 라이브러리

"내가 파일을 가지고 있지만, 어디에 있는 진 모르겠다"는 문제는 컴퓨터가 계층 파일 구조를 도입한 때 만큼이나 오랫동안 널리 있었습니다. 1974년 유닉스 5번째 판부터 find라는 명령어를 도입했고, find는 필수적인 명령어로 자리잡았습니다. 오랜 시간 다듬은 현재의 최신 기술로 현대 운영 체제는 발전한 문서 색인과 검색 능력을 가지게 됩니다.

프로그래머의 도구상자엔 아직 find같은 기능을 위한 중요한 장소가 있습니다. 이번 장에서, 우리는 하스켈 만으로 find의 여러 기능을 제공하는 라이브러리를 만들 것입니다. 이 라이브러리를 다양한 방법으로 만들어 보면서 제각기 어느 정도의 강력함을 가지는 지 알아볼 것입니다.

find 명령어

만약 유닉스 계열 운영 체제를 써본 적이 없거나, 열렬한 쉘 사용자가 아니라면, find를 들어본 적조차 없을 수도 있습니다. find는 디렉토리 목록을 주면 각각을 재귀적으로 검색해 표현식에 맞는 모든 항목을 출력해 주는 명령어입니다.

각각의 표현식은 "이 glob 패턴에 맞는 이름", "보통 파일", "이 날짜 이후에 수정한 파일" 등의 형식을 가지고 있습니다. 이 표현식들을 "and"나 "or" 연산자로 조합해 더 복잡한 표현식으로 조합할 수도 있습니다.

가벼운 시작: 디렉토리 재귀적으로 열거하기

라이브러리를 설계해보기 전에, 작은 문제들부터 풀어봅시다. 우리의 첫번째 문제는 디렉토리와 하위 디렉토리의 내용물을 재귀적으로 나열하는 것입니다.

-- file: ch09/RecursiveContents.hs
module RecursiveContents (getRecursiveContents) where

import Control.Monad (forM)
import System.Directory (doesDirectoryExist, getDirectoryContents)
import System.FilePath ((</>))

getRecursiveContents :: FilePath -> IO [FilePath]

getRecursiveContents topdir = do
  names <- getDirectoryContents topdir
  let properNames = filter (`notElem` [".", ".."]) names
  paths <- forM properNames $ \name -> do
    let path = topdir </> name
    isDirectory <- doesDirectoryExist path
    if isDirectory
      then getRecursiveContents path
      else return [path]
  return (concat paths)

filter 식은 디렉토리 내용에서 각각 현재 디렉토리와 부모 디렉토리를 의미하는 특별한 이름인 .과 ..을 제외해줍니다. 우리가 이걸 빼 놓는 걸 깜박한다면, 영원히 재귀하게 될 겁니다.

우리는 mapM의 인자가 뒤바뀐 것과 같은 forM을 이전 장에서 본 적이 있습니다.

ghci> :m +Control.Monad
ghci> :type mapM
mapM :: (Monad m) => (a -> m b) -> [a] -> m [b]
ghci> :type forM
forM :: (Monad m) => [a] -> (a -> m b) -> m [b]

반복문(이하 루프) 본체는 현재 디렉토리 항목이 디렉토리인지 아닌지 확인합니다. 만약 디렉토리라면, 그 디렉토리도 열거하기 위해 getRecursiveContents를 재귀적으로 호출합니다. 아니라면, 현재 항목의 이름을 담은 원소 하나짜리 리스트를 반환합니다. (return이 하스켈에서는 다른 의미를 가지는 걸 잊지 맙시다: return은 값을 모나드 타입 생성자로 감싸는 역할을 수행합니다.)

isDirectory변수를 사용했다는 점도 중요합니다. 파이썬같은 절차 지향 언어에선 일반적으로 if os.path.isdir(path)와 같이 작성합니다. 하지만 doesDirectoryExist 함수는 액션입니다; 이 함수의 리턴 타입은 IO Bool이지, Bool이 아닙니다. if 표현식이 Bool 타입 표현식을 필요로 하기 때문에 우리는 액션의 결과를 IO 타입 안에서 꺼내 Bool을 얻기 위해 <-를 써야 합니다. 그러면 이제 꺼낸 Boolif 표현식 안에 쓸 수 있게 됩니다.

루프 본체가 이름 리스트를 반환하므로 forM의 결과는 IO [[FilePath]]가 됩니다. 이걸 단일 리스트로 만들기 위해 concat을 쓸 수 있습니다.

익명 함수와 이름 있는 함수 복습

“익명 함수 (람다)”장에서, 익명 함수를 쓰면 안 되는 이유를 몇 가지 살펴봤습니다. 또한 익명 함수를 루프 본체로 써 봤습니다. 이건 하스켈에서 익명 함수의 가장 흔한 사용 예 중 하나입니다.

우리는 이미 forMmapM이 함수를 인자로 받는 것을 타입을 통해 살펴봤습니다. 대부분의 루프 본체는 프로그램에서 한 번만 나타나는 코드 블록입니다. 루프 본체를 딱 한 군데에서만 쓸 것 같다면, 그것에 이름을 줄 필요가 있을까요?

물론 때때로 정확히 같은 코드를 여러 다른 루프에 써야하는 일도 생깁니다. 이 경우에는 같은 익명 함수를 잘라내 붙여넣는 것보다, 이미 있는 익명 함수에 이름을 붙이는 것이 더 현실적입니다.

왜 mapM과 forM을 둘 다 제공하나요?

완전히 같지만 인자의 순서만 다른 두 함수가 있는 것이 좀 이상해 보일 수 있습니다. 하지만 mapM과 forM은 각각 다른 상황에서 편리합니다.

익명 함수를 루프 본체로 사용하는 위의 예를 생각해봅시다. 만약 우리가 forM대신 mapM을 사용한다면, properNames 변수를 루프 본체 뒤에 붙여야 할 것입니다. 또한 파싱이 제대로 되도록 익명 함수를 괄호로 감싸거나 한번만 쓸 이름을 붙여야 할 것입니다. 한 번 시도해보세요: 위의 코드를 복사하고, forMmapM으로 바꾸고, 코드 가독성에 어떤 영향을 주는지 확인해보세오.

반대로, 만약 루프 본체가 이미 이름을 가진 함수라면, 그리고 루프할 리스트가 복잡한 계산으로 표현된다면, 이건 mapM을 사용하면 되는 대표적인 경우가 될 것입니다.

여기서 mapM을 쓸지 forM을 쓸지 결정하는 스타일 규칙은 가장 간결한 코드가 되는 것을 고르는 것입니다. 루프 본체와 루프 데이터 표현식 계산이 둘 다 짧다면 둘 중 무엇을 써도 좋습니다. 만약 루프가 짧고, 데이터가 길다면 mapM을 쓰세요. 루프가 길고, 데이터가 짧다면, forM을 쓰세요. 둘 다 길다면, let이나 where로 하나를 짧게 만들면 됩니다. 약간의 연습으로 각각의 상황에서 어떤 접근 방법이 최선인지 쉽게 알 수 있을 것입니다.

순진한 탐색 함수

우리가 만든 getRecursiveContents 함수는 고민 없이 파일 검색기를 만드는 데 쓸 수 있습니다.

-- file: ch09/SimpleFinder.hs
import RecursiveContents (getRecursiveContents)

simpleFind :: (FilePath -> Bool) -> FilePath -> IO [FilePath]

simpleFind p path = do
  names <- getRecursiveContents path
  return (filter p names)

이 함수는 getRecursiveContents의 결과를 걸러낼 때 쓸 조건자predicate를 받습니다. 조건자로 완전한 경로가 전달되는데, 그래서 "확장자가 .c인 모든 파일을 찾아라"같은 일반적인 작업을 어떻게 시킬 수 있을까요?

System.FilePath 모듈은 파일 이름을 다루는 유용한 함수들을 많이 가지고 있습니다. 이번엔 우리는 takeExtension을 사용할 겁니다.

ghci> :m +System.FilePath
ghci> :type takeExtension
takeExtension :: FilePath -> String
ghci> takeExtension "foo/bar.c"
Loading package filepath-1.1.0.0 ... linking ... done.
".c"
ghci> takeExtension "quux"
""

이것으로 문제는 경로를 받고, 확장자를 추출해, ".c"하고 비교하는 함수를 만드는 것으로 간단해졌습니다.

ghci> :load SimpleFinder
[1 of 2] Compiling RecursiveContents ( RecursiveContents.hs, interpreted )
[2 of 2] Compiling Main             ( SimpleFinder.hs, interpreted )
Ok, modules loaded: RecursiveContents, Main.
ghci> :type simpleFind (\p -> takeExtension p == ".c")
simpleFind (\p -> takeExtension p == ".c") :: FilePath -> IO [FilePath]

simpleFind가 동작하긴 하지만, 몇가지 명백한 문제를 가지고 있습니다. 첫째로 조건자가 그다지 명확하지 못합니다. 조건자는 디렉토리 항목을 살펴볼 뿐, 그 항목이 파일인지 디렉토리인지는 구분하지 못 합니다. 즉 지금의 simpleFind.c로 끝나는 디렉토리도 결과에 포함한다는 얘깁니다.

둘째로 simpleFind는 우리에게 파일 시스템을 어떻게 순회할 지 전혀 제어권을 주지 않습니다. 이게 왜 중요한지 알기 위해, SVN으로 관리 중인 소스 파일들을 찾는 문제를 생각해봅시다. SVN은 .svn 디렉토리를 SVN이 관리하는 디렉토리마다 만들고 관리합니다. 각각의 .svn 디렉토리는 많은 파일과 하위 디렉토리를 가지고 있지만 우리의 관심사가 아닙니다. .svn을 포함한 경로를 제외하는 것으로 이 디렉토리 순회를 회피해 훨씬 효율을 올릴 수 있습니다. 예를 들어 우리가 45,000개의 파일 중 30,000개의 파일을 1,200개의 여러 .svn디렉토리에 저장한 SVN 폴더를 가지고 있다면 1,200개의 디렉토리 순회를 피하는 것이 30,000개의 파일을 제외하는 것보다 훨씬 저렴할 것입니다.

마지막으로, simpleFind는 엄격합니다. 왜냐하면 IO 모나드 안에서 실행하는 액션의 연쇄로 이루어졌기 때문입니다. 만약 우리가 백만 개의 파일을 순회한다면 꽤 긴 지연을 겪은 뒤 한꺼번에 백만 개의 이름을 받을 것입니다. 이것은 리소스 사용과 반응성 면에서 둘 다 안 좋습니다. 우리는 결과가 나오는 대로 처리하는 게으른 스트림을 더 선호할 겁니다.

이어지는 단락에서, 우리는 각각의 문제를 해결할 것입니다.

조건자: 순수함을 유지하면서, 약했지만 강력하게

우리 조건자는 파일 이름만 볼 수 있었습니다. 이건 우리가 원하는 다양한 행동을 제한합니다: 예를 들어, 주어진 크기보다 더 큰 파일만 열거하고 싶다면 어떨까요?

IO를 사용하자고 쉽게 반응할 수도 있습니다: 조건자의 타입을 FilePath -> Bool대신, FilePath -> IO Bool로 바꾸면 안 될까요? 이건 임의의 I/O작업을 조건자에서 할 수 있게 만들 겁니다. 매력적으로 보이지만, 이건 잠재적으로 문제를 가지고 있습니다: 조건자가 부수 효과를 가질 수 있다는 문제. IO 리턴 타입을 가진 함수는 어떤 부수 효과라도 일으킬 수 있습니다.

타입 시스템을 더 예측 가능하고 덜 장황한 코드를 짜는 데 활용하기 위해: "IO"를 사용하지 않는 것으로 조건자를 순수하게 유지할 것입니다. 이것은 조건자가 어떤 보기 싫은 부수효과도 가지지 않도록 합니다. 또한 위험해지지 않으면서 우리가 원하는 표현력을 얻기 위해 조건자에 좀 더 많은 정보를 제공할 것입니다.

하스켈의 이식성있는 System.Directory 모듈은 제한적이긴 하지만 유용한 메타데이터들을 제공해줍니다.

ghci> :m +System.Directory
  • doesFileExist와 doesDirectoryExist를 디렉토리 안 항목이 파일인지 디렉토리인지 확인하는 데 쓸 수 있습니다. 최근에 널리 쓰이게 된 지명 파이프나 하드 링크, 심볼릭 링크같은 다른 파일 타입들은 아직 이식성을 가지고 조사할 수 있는 방법이 없습니다.

    ghci> :type doesFileExist
    doesFileExist :: FilePath -> IO Bool
    ghci> doesFileExist "."
    Loading package old-locale-1.0.0.0 ... linking ... done.
    Loading package old-time-1.0.0.0 ... linking ... done.
    Loading package directory-1.0.0.0 ... linking ... done.
    False
    ghci> :type doesDirectoryExist
    doesDirectoryExist :: FilePath -> IO Bool
    ghci> doesDirectoryExist "."
    True
  • The getPermissions 함수로 어떤 작업이 해당 파일이나 디렉토리에 가능한지 확인할 수 있습니다.

    ghci> :type getPermissions
    getPermissions :: FilePath -> IO Permissions
    ghci> :info Permissions
    data Permissions
      = Permissions {readable :: Bool,
                     writable :: Bool,
                     executable :: Bool,
                     searchable :: Bool}
            -- Defined in System.Directory
    instance Eq Permissions -- Defined in System.Directory
    instance Ord Permissions -- Defined in System.Directory
    instance Read Permissions -- Defined in System.Directory
    instance Show Permissions -- Defined in System.Directory
    ghci> getPermissions "."
    Permissions {readable = True, writable = True, executable = False, searchable = True}
    ghci> :type searchable
    searchable :: Permissions -> Bool
    ghci> searchable it
    True

    (만약 ghci의 특별한 변수인 it이 떠오르지 않는다면 “타입으로의 첫걸음”을 돌아보세요.) 우리가 내용물을 열거할 권한이 있다면 그 디렉토리는 searchable한 것입니다. 파일은 절대 searchable할 수 없습니다.

  • 마지막으로, getModificationTime은 항목이 마지막으로 수정된 때를 알려줍니다.

    ghci> :type getModificationTime
    getModificationTime :: FilePath -> IO System.Time.ClockTime
    ghci> getModificationTime "."
    Mon Aug 18 12:08:24 CDT 2008

만약 우리가 이식성 있고 표준을 지키는 하스켈 코드를 고집한다면 이 함수들까지만 우리가 맘대로 쓸 수 있습니다. (파일 크기 또한 약간의 꼼수로 구할 수 있습니다. 아래를 보세요) 또한 이 함수들은 우리가 너무 방대한 예를 들 필요가 없이 배우고자 하는 원리를 설명하기에 충분합니다. 만약 더 요구조건이 많은 코드를 짜야 한다면, System.PosixSystem.Win32모듈이 두 주류 플랫폼에 대한 더 자세한 메타데이터를 제공해 줄 것입니다. 또한 Hackage에는 윈도에서 유닉스 계열의 API를 제공하는 unix-compat 패키지도 있습니다.

새롭고 더 강력한 조건자에는 얼마나 많은 데이터 조각이 필요할까요? 해당 항목의 Permissions를 보는 것으로 그것이 파일인지 디렉토리인지 알 수 있기 때문에, 우리는 doesFileExistdoesDirectoryExist의 결과를 참고할 필요가 없습니다. 그 결과 더 강력한 조건자가 필요로 하는 네 종류의 데이터를 알게 됩니다.

-- file: ch09/BetterPredicate.hs
import Control.Monad (filterM)
import System.Directory (Permissions(..), getModificationTime, getPermissions)
import System.Time (ClockTime(..))
import System.FilePath (takeExtension)
import Control.Exception (bracket, handle)
import System.IO (IOMode(..), hClose, hFileSize, openFile)

-- the function we wrote earlier
import RecursiveContents (getRecursiveContents)

type Predicate =  FilePath      -- path to directory entry
               -> Permissions   -- permissions
               -> Maybe Integer -- file size (Nothing if not file)
               -> ClockTime     -- last modified
               -> Bool

Predicate는 단지 4개의 인자를 가진 함수의 타입 동의어 입니다. 이것은 타이핑 작업을 줄이고 화면 공간을 절약해 우리를 도와줄 것입니다.

조건자의 리턴 타입이 IO Bool이 아니라 Bool이라는 데 주목해주세요: 조건자는 순수하고, 어떤 입출력 작업도 하지 않습니다. 이 타입으로 더 표현력 있는 조건자 함수를 잘 다듬습니다.

-- file: ch09/BetterPredicate.hs
-- soon to be defined
getFileSize :: FilePath -> IO (Maybe Integer)

betterFind :: Predicate -> FilePath -> IO [FilePath]

betterFind p path = getRecursiveContents path >>= filterM check
    where check name = do
            perms <- getPermissions name
            size <- getFileSize name
            modified <- getModificationTime name
            return (p name perms size modified)

코드를 따라가 봅시다. getFileSize는 곧 자세히 설명하므로, 지금은 넘어갑니다.

우리는 조건자 p를 호출하기 위해 filter를 사용할 수 없습니다. 왜냐하면 p의 순수성이 우리가 필요로하는 메타데이터를 얻기 위한 입출력 작업을 할 수 없게 만들기 때문입니다.

때문에 낯선 함수인 filterM을 도입합니다. 이건 기존의 filter 함수와 비슷하지만, 이 경우 filterM은 조건자가 입출력 작업을 할 수 있도록 조건자를 IO 모나드 안에서 평가합니다.

ghci> :m +Control.Monad
ghci> :type filterM
filterM :: (Monad m) => (a -> m Bool) -> [a] -> m [a]

check조건자는 순수한 조건자 p를 감싸는 입출력 가능한 래퍼wrapper입니다.check는 모든 "더러운" 입출력 작업을 p를 위해서 해주고, 그 결과 우리는 p가 원치 않는 부수효과를 낼 수 없도록 합니다. 메타데이터를 모든 다음, checkp를 호출해 return으로 p의 결과를 IO로 감쌉니다.

안전하게 파일 크기 조절하기

비록 System.Directory로 파일이 얼마나 큰지 알 수 없다지만, 유사하게 이식성 있는 System.IO 모듈을 사용해 구할 수 있습니다. 이 모듈은 hFileSize라는 연 파일의 크기를 바이트로 알려주는 함수를 가지고 있습니다. 아래는 그것을 감싸는 간단한 함수입니다.

-- file: ch09/BetterPredicate.hs
simpleFileSize :: FilePath -> IO Integer

simpleFileSize path = do
  h <- openFile path ReadMode
  size <- hFileSize h
  hClose h
  return size

이 함수는 동작하긴 하지만, 우리가 사용하기엔 아직 적절하지 않습니다. betterFind에서 우리는 getFileSize를 무차별적으로 모든 디렉토리 항목에 사용하고 있습니다; 만약 항목이 일반 파일이 아니면 이 함수는 Nothing을 반환하고, 크기는 Just로 감싸야 합니다. 그러지 않으면 함수는 일반 파일이 아니거나 (아마도 권한이 부족한 상황에서) 열 수 없을 때 예외를 던지고 감싸지 않은 크기를 반환할 겁니다.

아래는 더 안전한 버전의 함수입니다.

-- file: ch09/BetterPredicate.hs
saferFileSize :: FilePath -> IO (Maybe Integer)

saferFileSize path = handle (\_ -> return Nothing) $ do
  h <- openFile path ReadMode
  size <- hFileSize h
  hClose h
  return (Just size)

handle구문을 제외한 함수 본체는 거의 동일합니다.

위에 나온 예외 핸들러는 받은 예외를 무시하고 Nothing을 반환합니다. 뒤따라오는 함수 본체에 생긴 유일한 변화는 파일 크기를 Just로 감싼 것입니다.

saferFileSize 함수는 이제 제대로 된 타입 시그니처를 가지고, 더 이상 예외를 던지지 않을 것입니다. 하지만 아직 완전히 잘 동작하지는 않습니다. openFile은 성공하지만 hFileSize에서 예외가 생기는 디렉토리 항목이 있습니다. 이건 지명 파이프 같은 항목에서 발생할 수 있습니다. 이러한 예외는 handle에 의해 잡히지만, hClose는 일어나지 않을 것입니다.

하스켈 구현은 핸들을 더 이상 사용하지 않는 상황을 감지하면 자동으로 핸들을 닫습니다. 이것은 가비지 컬렉터가 실행되기 전까진 일어나지 않을 것이고, 다음 가비지 컬렉션이 실행될 때까지의 지연은 예측할 수 없습니다.

파일 핸들은 부족할 수 있는 리소스입니다. 밑단인 운영 체제에서 이 제한을 강제합니다. 예를 들어 리눅스에선, 한 프로세스는 기본으로 1024개 까지만 파일 핸들을 동시에 열 수 있습니다.

saferFileSize를 호출하는 betterFind를 사용하는 프로그램이 betterFind가 쓰레기 파일 핸들이 닫히기 전에 파일 핸들을 고갈내 프로그램을 크래시 낸다는 상상은 어렵지 않게 할 수 있습니다.

이건 특히 치명적이고 알아차리기 힘든 종류의 버그입니다: 이것은 합쳐지면 상당히 추적을 어렵게 하는 여러 면을 가지고 있습니다. 이 버그는 betterFind가 프로세스가 열 수 있는 파일 핸들 제한을 넘는 갯수의 비일반 파일을 맞닥뜨려야 하고 쌓인 쓰레기 파일 핸들이 닫히기 전에 다른 파일을 열려고 하는 호출자에게 반환되어야만 발생할 것입니다.

설상가상으로, 프로그램에서 더 이상 접근할 수 없는 데이터와 가비지 컬렉션되지 않은 데이터 때문에 발생하는 뒤따르는 에러가 있습니다. 이런 버그는 프로그램 구조와 파일시스템 내용, 프로그램 흐름이 가비지 컬렉터 실행과 얼마나 가까운지에 따라 갈립니다.

이런 종류의 문제는 개발 도중엔 간과하기 쉽지만, 나중에 현장에서 일어날 땐 (이런 이상한 문제가 항상 일어나듯이), 진단하기는 매우 힘들 것입니다.

다행히도, 우리는 함수를 더 짧게 만들면서도 이런 종류의 에러를 매우 쉽게 피할 수 있습니다.

획득-사용-해제 사이클

우리는 openFile이 성공하면 반드시 hClose를 호출해야 합니다. Control.Exception 모듈은 정확히 이 용도를 위한 bracket 함수를 제공합니다.

ghci> :type bracket
bracket :: IO a -> (a -> IO b) -> (a -> IO c) -> IO c

bracket 함수는 세 액션을 인자로 받습니다. 첫째 액션은 자원을 획득합니다. 둘째 액션은 자원을 해제합니다. 셋째 액션은 자원을 획득한 사이에 실행되는 액션입니다; 이걸 "사용" 액션이라고 부릅시다. 만약 "획득" 액션이 성공하면 "해제" 액션은 항상 호출될 것입니다. 이건 자원이 항상 해제될 것을 보장합니다. "사용"과 "해제" 액션에 전달되는 자원은 "획득" 액션에서 얻은 자원입니다.

만약 예외가 "사용" 액션 실행 중에 일어났다면, bracket은 “해제” 액션을 호출하고 예외를 다시 던집니다. 만약 “사용” 액션이 성공하면, bracket은 “해제” 액션을 호출하고, “사용” 액션에서 반환한 값을 반환합니다.

우리는 이제 완전히 안전한 함수를 작성할 수 있습니다: 이 함수는 예외를 던지지 않고, 프로그램 어딘가에서 이상한 실패를 일으킬 수 있는 쓰레기 파일 핸들을 축적하지도 않을 것입니다.

-- file: ch09/BetterPredicate.hs
getFileSize path = handle (\_ -> return Nothing) $
  bracket (openFile path ReadMode) hClose $ \h -> do
    size <- hFileSize h
    return (Just size)

bracket의 인자를 자세히 보세요. 첫째 인자는 파일을 열고, 연 파일 핸들을 반환합니다. 둘째 인자는 파일 핸들을 닫습니다. 셋째 인자는 단순히 파일 핸들에 hFileSize 를 호출하고 그 결과를 Just로 강쌉니다.

우리는 이 함수를 제대로 동작시키기 위해 brackethandle을 둘 다 써야 합니다. 전자는 우리가 쓰레기 파일 핸들을 축적하지 않는 것을 보장하고, 후자는 예외를 없애는 것을 보장합니다.

연습문제

  1. brackethandle의 순서가 중요할까요? 그 이유는?

조건자를 위한 도메인 언어Domain specific language

조건자를 만들기 위한 첫발을 내디뎌봅시다. 우리가 만들 조건자는 128KB가 넘는 C++ 소스 파일을 찾아낼 겁니다.

-- file: ch09/BetterPredicate.hs
myTest path _ (Just size) _ =
    takeExtension path == ".cpp" && size > 131072
myTest _ _ _ _ = False

이건 딱히 즐겁지 않습니다. 이 조건자는 4개의 인자를 받고, 항상 그 중 2개는 무시하며, 두 개의 등식을 을 정의하는 걸 요구합니다. 물론 개량의 여지가 있습니다. 우리가 더 간결한 조건자를 작성하는 걸 도와줄 약간의 코드를 만들어 봅시다.

때때로, 이러한 종류의 라이브러리는 임베디드 도메인 언어라고 부릅니다: 프로그래밍 언어의 내재된 기능만으로 (그래서 임베디드) 우리가 더 좁은 문제를 특별히 우아하게 해결하는 코드를 짭니다. (그래서 영역 한정)

첫 단계는 인자 중 하나를 반환하는 함수를 짜는 것입니다. 아래 함수는 Predicate에 넘긴 인자 중 경로를 추출합니다.

-- file: ch09/BetterPredicate.hs
pathP path _ _ _ = path

만약 우리가 타입 시그니처를 제공하지 않는다면, 하스켈 구현은 이 함수를 매우 일반화한 타입으로 추측할 겁니다. 이건 나중에 해석하기 힘든 에러 메시지를 초래할 수 있으므로, pathP에 타입을 줘 봅시다.

-- file: ch09/BetterPredicate.hs
type InfoP a =  FilePath        -- path to directory entry
             -> Permissions     -- permissions
             -> Maybe Integer   -- file size (Nothing if not file)
             -> ClockTime       -- last modified
             -> a

pathP :: InfoP FilePath

이제 다른 비슷한 구조를 가질 함수들을 정의할 때 간편히 사용할 수 있는 타입 동의어를 만들었습니다. 이 타입 동의어는 타입 인자를 받으므로 우리는 각기 다른 결과 타입을 지정할 수 있습니다.

-- file: ch09/BetterPredicate.hs
sizeP :: InfoP Integer
sizeP _ _ (Just size) _ = size
sizeP _ _ Nothing     _ = -1

(여기서 살짝 찝찝하지만, 파일이 아니거나 열 수 없는 파일에 대해선 -1을 반환합니다.)

사실, 우리가 이 장이 시작할 때 정의한 Predicate 타입은 얼핏 보기에 InfoP Bool과 같습니다. (그러므로 우리는 합법적으로 Predicate 타입을 지울 수 있습니다.)

pathPsizeP의 용도는 뭘까요? 약간의 접착제와 함께, 우리는 이걸 조건자 안에서 사용할 수 있습니다 (각각의 이름의 P 접미사는 “조건자”라는 걸 나타내기 위함입니다). 여기서부터 재밌어지기 시작합니다.

-- file: ch09/BetterPredicate.hs
equalP :: (Eq a) => InfoP a -> a -> InfoP Bool
equalP f k = \w x y z -> f w x y z == k

equalP의 타입 시그니처는 관심을 기울일 만 합니다. pathPsizeP 둘 다와 호환되는 InfoP a를 받습니다. 이건 a를 받습니다. 그리고 이미 Predicate와 타입 동의어인 걸 확인한 InfoP Bool을 반환합니다. 다시 말하자면, equalP는 조건자를 만듭니다.

equalP 함수는 받은 인자를 f에 넘겨 그 결과를 k와 비교하는, 조건자로 인정되는 익명 함수를 반환하는 것으로 동작합니다.

equalP 등식은 우리가 이걸 두 인자를 받는 함수라고 간주하는 부분을 강조합니다. 하스켈은 모든 함수를 커리하기 때문에, equalP를 꼭 이런 방식으로 작성할 필요는 없습니다. 익명 함수를 생략하고 커리로 대신해서, 동일한 함수를 재작성할 수 있습니다.

-- file: ch09/BetterPredicate.hs
equalP' :: (Eq a) => InfoP a -> a -> InfoP Bool
equalP' f k w x y z = f w x y z == k

탐구를 시작하기 전에, 우리 모듈을 ghci에 로딩해봅시다.

ghci> :load BetterPredicate
[1 of 2] Compiling RecursiveContents ( RecursiveContents.hs, interpreted )
[2 of 2] Compiling Main             ( BetterPredicate.hs, interpreted )
Ok, modules loaded: RecursiveContents, Main.

이 함수로 만든 간단한 조건자가 잘 동작하는지 확인해 봅시다.

ghci> :type betterFind (sizeP `equalP` 1024)
betterFind (sizeP `equalP` 1024) :: FilePath -> IO [FilePath]

실제로 betterFind를 호출한 게 아니라, 그저 표현식의 타입만 확인한 것입니다. 이제 우리는 정확히 특정 크기의 파일을 모두 열거하는 더 표현력있는 방법을 가지게 되었습니다. 이 성공은 계속할 자신감을 줍니다.

리프팅으로 보일러플레이트 피하기

equalP 말고도, 우리는 다른 이항 함수들을 만들고 싶습니다. 쓸데없이 장황해질 것 같기 때문에, 각각을 완벽하게 정의하지 않는 편을 선호할 것입니다.

이 문제를 다루기 위해, 하스켈의 강력한 추상화를 이용해봅시다. equalP의 정의를 취하고(==)을 직접 호출하는 대신 우리가 원하는 이항 연산을 또 다른 인자로 넘겨줄 것입니다.

-- file: ch09/BetterPredicate.hs
liftP :: (a -> b -> c) -> InfoP a -> b -> InfoP c
liftP q f k w x y z = f w x y z `q` k

greaterP, lesserP :: (Ord a) => InfoP a -> a -> InfoP Bool
greaterP = liftP (>)
lesserP = liftP (<)

greaterP같은, (>)같은 함수를 받아서 다른 문맥에서 동작하는 다른 함수로 변환하는 행위를 그 문맥으로 리프팅한다고 말합니다. 이건 lift가 함수 이름에 붙는 이유를 설명합니다. 리프팅은 우리가 코드를 재활용하고 보일러플레이트를 줄이도록 도와줍니다. 우리는 여러 모양새로 이 책의 나머지 부분에서 리프팅을 많이 사용할 것입니다.

우리가 함수를 리프트할 때, 원래 함수와 새 함수를 각각 리프트 안 된 함수unlifted와 리프트된 함수lifted라고 부릅니다.

어쨌든, liftP의 인자로써 q(리프트할 함수)의 위치는 다분히 계획적입니다. 이것은 우리가 greaterPlesserP를 간결하게 정의하는 걸 가능하게 만듭니다. API를 설계할 때 이런 부분 적용은 “최선”의 인자 순서를 찾아내는 걸 하스켈에서 다른 언어보다 더 중요하게 만듭니다. 부분 적용이 없는 언어에서는, 인자 순서는 단지 취향과 관습의 문지입니다. 하지만 하스켈에서 인자 위치를 부적절하게 정하는 것은, 부분 적용이 주는 간결해질 수 있는 기회를 잃는 게 됩니다.

컴비네이터로 그런 간결함을 일부 회복할 수도 있습니다. 예를 들어, forMControl.Monad 모듈에 2007년까지 없었습니다. 그 이전에는, 사람들은 flip mapM을 대신 사용했습니다.

ghci> :m +Control.Monad
ghci> :t mapM
mapM :: (Monad m) => (a -> m b) -> [a] -> m [b]
ghci> :t forM
forM :: (Monad m) => [a] -> (a -> m b) -> m [b]
ghci> :t flip mapM
flip mapM :: (Monad m) => [a] -> (a -> m b) -> m [b]

조건자 이어붙이기

조건자를 합성하고 싶다면, 물론 아래의 명백한 수작업을 할 수 있습니다.

-- file: ch09/BetterPredicate.hs
simpleAndP :: InfoP Bool -> InfoP Bool -> InfoP Bool
simpleAndP f g w x y z = f w x y z && g w x y z

이제 우리는 리프팅을 알고 있으니, 위에 있는 불리언 연산자를 리프팅해서 우리가 작성해야 하는 코드의 양을 줄이는 것이 더 자연스러워집니다.

-- file: ch09/BetterPredicate.hs
liftP2 :: (a -> b -> c) -> InfoP a -> InfoP b -> InfoP c
liftP2 q f g w x y z = f w x y z `q` g w x y z

andP = liftP2 (&&)
orP = liftP2 (||)

liftP2가 이전에 있었던 liftP와 비슷하다는 점에 주목하세요. 사실, 이게 더 일반적인데, 왜냐하면 liftPliftP2로 작성할 수 있기 때문입니다.

-- file: ch09/BetterPredicate.hs
constP :: a -> InfoP a
constP k _ _ _ _ = k

liftP' q f k w x y z = f w x y z `q` constP k w x y z

컴비네이터

하스켈에서, 다른 함수를 인자로 받아 새 함수를 반환하는 함수를 컴비네이터라고 부릅니다.

이제 우리는 여러 도우미 함수를 준비했으니, 이전에 정의했던 myTest 함수로 돌아가 봅시다.

-- file: ch09/BetterPredicate.hs
myTest path _ (Just size) _ =
    takeExtension path == ".cpp" && size > 131072
myTest _ _ _ _ = False

우리가 만든 새 컴비네이터를 사용하면 이 함수가 어떻게 보일까요?

-- file: ch09/BetterPredicate.hs
liftPath :: (FilePath -> a) -> InfoP a
liftPath f w _ _ _ = f w

myTest2 = (liftPath takeExtension `equalP` ".cpp") `andP`
          (sizeP `greaterP` 131072)

마지막 컴비네이터로 liftPath를 추가했는데, 파일 이름을 조작하는 건 흔한 작업이기 때문입니다.

새 연산자 정의하고 사용하기

우리의 도메인 언어를 새 중위 연산자를 정의해 더 심화시킬 수 있습니다.

-- file: ch09/BetterPredicate.hs
(==?) = equalP
(&&?) = andP
(>?) = greaterP

myTest3 = (liftPath takeExtension ==? ".cpp") &&? (sizeP >? 131072)

대응되는 리프트 안 된 연산자와 시각적으로 일관되기 위해 (==?)같은 이름을 골랐습니다.

myText3 정의의 괄호는 필수인데, 그 이유는 우리가 아직 하스켈에게 새 연산자에 대한 우선순위나 결합 방향을 지정하지 않았기 때문입니다. 하스켈은 이런 fixity 지정이 없는 연산자들을 반드시 infixl 9로 취급한다고 명시했습니다. 즉, 이 연산자들은 왼쪽에서 오른쪽으로, 가장 높은 우선순위로 평가된다는 뜻입니다. 만약 우리가 괄호를 생략한다면, 표현식은 끔찍하게 잘못된 (((liftPath takeExtension) ==? ".cpp") &&? sizeP) >? 131072로 해석될 겁니다.

우리는 새 연산자의 fixity 선언을 넣어 대응할 수 있습니다. 첫째 단계는 리프트되지 않은 연산자의 fixity를 우리가 흉내낼 수 있도록 확인하는 것입니다.

ghci> :info ==
class Eq a where
  (==) :: a -> a -> Bool
  ...
        -- Defined in GHC.Base
infix 4 ==
ghci> :info &&
(&&) :: Bool -> Bool -> Bool      -- Defined in GHC.Base
infixr 3 &&
ghci> :info >
class (Eq a) => Ord a where
  ...
  (>) :: a -> a -> Bool
  ...
        -- Defined in GHC.Base
infix 4 >

이것을 가지고, 이제 괄호가 없으면서 myTest3와 동일하게 해석되는 함수를 짤 수 있습니다.

-- file: ch09/BetterPredicate.hs
infix 4 ==?
infixr 3 &&?
infix 4 >?

myTest4 = liftPath takeExtension ==? ".cpp" &&? sizeP >? 131072

순회 제어

파일 시스템을 순회할 때, 우리 자신에게 어떤 디렉토리를 들어갈지 말지, 혹은 언제 들어갈지에 대한 제어를 주고 싶습니다. 이걸 허용하는 쉬운 방법은 함수 안에서 주어진 디렉토리의 하위 디렉토리 리스트를 받아 다른 리스트를 반환하는 것입니다. 반환한 리스트는 원소가 제거되었을 수도 있고, 원래 리스트와 순서가 다르거나, 혹은 둘 다일 수도 있습니다. 가장 간단한 제어 함수인 id는 리스트를 있는 그대로 돌려줄 겁니다.

다양성을 위해, 표현의 일부분을 바꿔볼 것입니다. InfoP a로 일관하는 대신 내부적으로 나타내는 건 비슷하지만 일반적인 대수적 데이터 타입을 사용할 겁니다.

-- file: ch09/ControlledVisit.hs
data Info = Info {
      infoPath :: FilePath
    , infoPerms :: Maybe Permissions
    , infoSize :: Maybe Integer
    , infoModTime :: Maybe ClockTime
    } deriving (Eq, Ord, Show)

getInfo :: FilePath -> IO Info

“간단히 쓸 수 있는” 접근자를 얻기 위해 infoPath같은 레코드 구문을 사용했습니다. traverse 함수의 타입은 위에서 제안한 대로 간단합니다. 파일이나 폴더의 Info 를 얻기 위해 getInfo 액션을 호출할 겁니다.

-- file: ch09/ControlledVisit.hs
traverse :: ([Info] -> [Info]) -> FilePath -> IO [Info]

traverse의 정의는 짧지만, 빽빽합니다.

-- file: ch09/ControlledVisit.hs
traverse order path = do
    names <- getUsefulContents path
    contents <- mapM getInfo (path : map (path </>) names)
    liftM concat $ forM (order contents) $ \info -> do
      if isDirectory info && infoPath info /= path
        then traverse order (infoPath info)
        else return [info]

getUsefulContents :: FilePath -> IO [String]
getUsefulContents path = do
    names <- getDirectoryContents path
    return (filter (`notElem` [".", ".."]) names)

isDirectory :: Info -> Bool
isDirectory = maybe False searchable . infoPerms

여기서 어떤 기법도 소개하진 않을 테지만, 이건 우리가 맞닥뜨린 함수 중 가장 밀도 높은 함수 중 하나입니다. 줄 단위로 짚어가면서 어떻게 되는지 설명해 봅시다. 처음 두 줄은 우리가 이미 본 코드를 말 그대로 복사한 것과 같기에 어떤 까다로운 점도 없습니다.

contents 변수에 할당할 때부터 흥미진진해집니다. 이 줄을 오른쪽에서 왼쪽으로 읽어보세요. 우리는 이미 names가 디렉토리 항목이라는 걸 알고 있습니다. 현재 디렉토리가 각각의 원소의 앞쪽에 붙고, 현재 디렉토리 자체도 리스트에 추가되는 것을 확인할 수 있습니다. 그 후 getInfo를 얻어낼 경로에 적용하기 위해 mapM을 사용합니다.

그 다음 줄은 더 빽빽합니다. 다시 오른쪽에서 왼쪽으로 읽으면, 이줄의 마지막 요소가 문단 끝까지 이어지는 익명 함수의 정의를 시작하고 있는 걸 볼 수 있습니다. 주어진 Info 값에 따라, 이 함수는 디렉토리를 재귀적으로 방문하거나 (여기에 path를 다시 방문하지 않도록 추가로 확인하고), (traverse의 결과 타입에 맞추기 위해) 그 값을 단일 원소 리스트로 반환합니다.

사용자가 제공한 순회 제어 함수 order에서 반환한 Info 리스트의 원소 각각에 forM으로 이 함수를 적용합니다.

줄 시작에서, 리프팅 기법을 새 문맥에서 사용했습니다. liftM 함수는 일반 함수 concat을 받아 IO 모나드로 리프팅합니다. 달리 말하자면, 이 함수는 (IO [[Info]] 타입인) forM의 결과에서 IO 모나드의 내용물을 꺼내 (우리가 원하는 [Info] 타입을 반환하는) concat으로 붙인 다음 , 그 결과를 다시 IO 모나드 안에 집어넣습니다.

마지막으로, getInfo 함수를 정의하는 걸 잊으면 안 됩니다.

-- file: ch09/ControlledVisit.hs
maybeIO :: IO a -> IO (Maybe a)
maybeIO act = handle (\_ -> return Nothing) (Just `liftM` act)

getInfo path = do
  perms <- maybeIO (getPermissions path)
  size <- maybeIO (bracket (openFile path ReadMode) hClose hFileSize)
  modified <- maybeIO (getModificationTime path)
  return (Info path perms size modified)

여기서 주목할 딱 하나는 예외를 던지는 IO 액션의 결과를 Maybe로 감싸는 유용한 컴비네이터인 maybeIO입니다.

연습문제

  1. 알파벳 역순 순서로 디렉토리 트리를 순회하려면 traverse에 어떤 함수를 넘겨야 할까요?

  2. id를 제어함수로 쓰면, traverse id전위 순회를 합니다: 즉 자식 전에 부모 디렉토리를 먼저 반환합니다. traverse가 자신을 먼저 반환하는 후위 순회를 하도록 하는 제어 함수를 작성해 보세요.

  3. “조건자 이어붙이기”에서 조건자와 컴비네이터를 빌려와서 그것들이 이번에 새로 만든 Info 타입에 동작하도록 해보세요.

  4. 순회 제어와 결과를 거르는 데에 조건자를 각각 받는 traverse의 래퍼 함수를 작성해 보세요.

밀도, 가독성, 학습 절차

하스켈에서 traverse만큼 조밀한 코드는 이상하지 않습니다. 표현력을 얻는 것은 중요하고, 이러한 스타일의 코드를 짜거나 읽는 것은 약간의 연습을 필요로 합니다.

비교를 위해, 덜 조밀한 같은 코드를 여기 준비했습니다. 아마 하스켈에 덜 익숙한 프로그래머에겐 더 친숙할 것입니다.

-- file: ch09/ControlledVisit.hs
traverseVerbose order path = do
    names <- getDirectoryContents path
    let usefulNames = filter (`notElem` [".", ".."]) names
    contents <- mapM getEntryName ("" : usefulNames)
    recursiveContents <- mapM recurse (order contents)
    return (concat recursiveContents)
  where getEntryName name = getInfo (path </> name)
        isDirectory info = case infoPerms info of
                             Nothing -> False
                             Just perms -> searchable perms
        recurse info = do
            if isDirectory info && infoPath info /= path
                then traverseVerbose order (infoPath info)
                else return [info]

바꾼 것은 약간의 치환을 한 것이 전부입니다. 마음껏 부분 적용과 함수 합성을 하는 대신 where 블록에 지역 함수를 정의했습니다. maybe 컴비네이터 자리엔 case 표현식을 사용했습니다. 또 liftM을 쓰는 대신 직접 concat을 리프팅했습니다.

조밀한 것이 전반적으로 좋다고 말하는 것이 아닙니다. 원래의 traverse 함수는 줄 각각이 짧았습니다. 우리는 줄 길이를 줄이고 코드를 명확하게 하기 위해 지역 변수 (usefulNames)와 지역 함수 (isDirectory)를 추가했습니다. 이 이름들은 설명적입니다. 함수 합성과 파이프라이닝을 하긴 하지만, 가장 긴 파이프라인도 세 개의 원소만 담고 있을 뿐입니다.

유지보수가 가능한 하스켈 코드를 짜는 열쇠는 밀도와 가독성 간의 균형을 맞추는 것입니다. 이 연속체에서 독자의 코드가 떨어지는 부분은 독자의 경험 수준에 영향을 받을 듯 합니다.

  • 초보 하스켈 프로그래머 앤드류는 표준 라이브러리로 원하는 바를 이루는 방법을 잘 모릅니다. 그 결과, 이미 많이 존재하는 코드를 알지도 못하고 중복시킵니다.

  • 잭은 몇달간 프로그래밍을 해왔고, (.)으로 긴 코드 파이프라인을 짜는 것을 숙달했습니다. 그의 프로그램을 살짝 바꿀 필요가 있을 때마다, 그는 새 파이프라인을 처음부터 짜야 합니다: 그는 기존의 파이프라인을 더 이상 이해하지 못 하고, 어떻게 짰든 간에 코드는 변화에 매우 약합니다.

  • 모니카는 코딩을 상당기간 해왔습니다. 그녀는 하스켈 라이브러리와 탄탄한 코드를 짜는 관용구에 충분히 익숙하지만, 너무 조밀한 스타일은 지양합니다. 그녀의 코드는 유지보수가 편하고, 요구 조건을 바꿀꿀 때마다 리팩토링이 쉬운 것을 발견합니다.

순회를 보는 다른 방법

traverse 함수로 betterFind 함수보다 더 많은 제어가 가능해졌지만, 아직 중요한 결점이 남아있습니다: 우리는 디렉토리 안으로 재귀하는 것을 피할 수 있지만, 트리 안의 모든 항목의 리스트를 만들기 전까지 다른 항목을 걸러낼 수 없습니다. 만약 우리가 3개의 파일을 찾기 위해 100,000개의 파일을 담고있는 뒤져야 한다면, 3개로 걸러낸 결과를 얻기 전에 100,000개의 원소를 가진 리스트를 만들게 될 것입니다.

한 가지 대안으로 리스트를 조립할 때마다 적용할 필터 함수를 traverse에 새 인자로 주는 것이 있습니다. 이렇게 하면 우리가 필요한 만큼만 리스트를 할당할 것입니다.

하지만, 이 방법도 약점이 있는데: 우리가 최대 3개 항목만 원하고, 그 항목이 100,000개의 항목을 순회하는 동안 맨 처음 3개에서 전부 발견한다면, 우리는 나머지 99,997개의 항목을 무의미하게 순회할 것입니다. 이건 딱히 억지로 생각한 예가 아닙니다. 예를 들어, Maildir의 메일 보관함 형식은 이메일 수신함을 각각의 메일 파일을 가진 디렉토리로 저장합니다. 메일 수신함을 나타내는 한 디렉토리가 수만 개의 파일을 가지고 있는 것을 흔한 일입니다.

우리는 앞서 본 두 함수의 약점을 다른 관점에서 바라봄으로써 해결할 수 있습니다. 파일시스템 순회를 디렉토리 계층구조를 접는 것으로 생각하면 어떨까요?

친숙한 접기 함수인 foldrfoldl'은 결과를 누적하는 것으로 리스트를 순회한다는 개념을 알맞게 일반화 합니다. 리스트를 접는다는 개념을 디렉토리 트리로 확장하는 것은 쉽지 않지만, 우리는 접기에 제어 요소를 넣고 싶습니다. 우리는 이 제어를 대수적 데이터 타입으로 나타낼 것입니다.

-- file: ch09/FoldDir.hs
data Iterate seed = Done     { unwrap :: seed }
                  | Skip     { unwrap :: seed }
                  | Continue { unwrap :: seed }
                    deriving (Show)

type Iterator seed = seed -> Info -> Iterate seed

Iterator 타입은 우리가 접기에 쓸 함수를 위한 편리한 별명을 제공합니다. 이 타입은 씨앗과 디렉토리 항목을 나타내는 Info값을 받고 새 씨앗과 Iterate 타입 생성자로 나타내고 있는 접기 함수를 위한 명령을 반환합니다.

  • 명령이 Done이면, 즉시 순회를 중단합니다. 그러고 결과를 Done 으로 감싸 반환할 것입니다.

  • 명령이 Skip이고 현재 Info가 디렉토리를 나타내면, 그 디렉토리 안으로 순회하지 않고 넘어갑니다.

  • 전부 아니면, 감싼 값을 다음 접기 함수의 입력으로 넘기고 순회를 계속 합니다.

이번 폴드는 논리적으로 왼쪽 접기 종류입니다. 왜냐하면 맞닥뜨리는 첫째 항목부터 접기를 시작할 것이고, 각 단계의 씨앗은 이전 단계의 결과이기 때문입니다.

-- file: ch09/FoldDir.hs
foldTree :: Iterator a -> a -> FilePath -> IO a

foldTree iter initSeed path = do
    endSeed <- fold initSeed path
    return (unwrap endSeed)
  where
    fold seed subpath = getUsefulContents subpath >>= walk seed

    walk seed (name:names) = do
      let path' = path </> name
      info <- getInfo path'
      case iter seed info of
        done@(Done _) -> return done
        Skip seed'    -> walk seed' names
        Continue seed'
          | isDirectory info -> do
              next <- fold seed' path'
              case next of
                done@(Done _) -> return done
                seed''        -> walk (unwrap seed'') names
          | otherwise -> walk seed' names
    walk seed _ = return (Continue seed)

이 코드를 작성한 방식에 몇 가지 흥미로운 점이 있습니다. 첫째로 여분의 인자를 넘기는 것을 막기 위해 영역을 사용한 것입니다. 최상위 foldTree 함수는 fold의 최종 결과에서 생성자를 벗겨내기 위한 단순한 래퍼입니다.

fold가 지역함수라서 foldTreeiter 인자를 따로 넘겨줄 필요가 없습니다. 이미 바깥 영역에 접근할 수 있기 때문이죠. 마찬가지로 walk 또한 바깥 영역에 있는 path를 볼 수 있습니다.

또 달리 주목할 점은 이전 함수에선 forM에서 익명 함수를 호출한 것 대신 walk라는 꼬리 호출 함수를 사용했다는 것입니다. 필요하다면 고삐를 잡아 빨리 멈출 수 있습니다. 반복자가 Done을 반환하도록 해서 말이죠.

foldwalk를 호출하고 walkfold를 재귀적으로 호출해 하위 디렉토리를 순회합니다. 각각의 함수는 Iterate로 감싼 씨앗을 반환합니다. walkfold를 호출하고 그 결과를 조사해 계속 진행할 지 Done이어서 중단해야 할 지를 판단합니다. 이런 식으로, 호출자가 제공한 반복자는 Done을 반환하는 것으로 상호 재귀적인 호출을 단번에 끝낼 수 있습니다.

반복자는 실제로 어떻게 보일까요? 여기 SVN 메타데이터 디렉토리를 제외하고 최대 3개의 비트맵 파일을 찾는 약간 복잡할 수 있는 예제가 있습니다.

-- file: ch09/FoldDir.hs
atMostThreePictures :: Iterator [FilePath]

atMostThreePictures paths info
    | length paths == 3
      = Done paths
    | isDirectory info && takeFileName path == ".svn"
      = Skip paths
    | extension `elem` [".jpg", ".png"]
      = Continue (path : paths)
    | otherwise
      = Continue paths
  where extension = map toLower (takeExtension path)
        path = infoPath info

이걸 사용하려면, IO [FilePath]를 반환해주는 foldTree atMostThreePictures []를 호출합니다.

물론 반복자는 저렇게까지 복잡해질 필요는 없습니다. 이건 발견한 디렉토리의 갯수를 세는 반복자입니다.

-- file: ch09/FoldDir.hs
countDirectories count info =
    Continue (if isDirectory info
              then count + 1
              else count)

여기선 처음 foldTree에 넘기는 씨앗은 숫자 0이 되어야 할 겁니다.

연습문제

  1. 호출자가 디렉토리 안 항목의 순회 순서를 바꿀 수 있도록 foldTree를 수정해보세요.

  2. foldTree 함수는 전위 순회를 합니다. 호출자가 순회 순서를 결정할 수 있도록 수정해보세요.

  3. foldTree가 받는 반복자를 표현하는 컴비네이터 라이브러리를 만들어 보세요. 그것이 독자가 작성하는 반복자를 더 간단명료하게 만드나요?

유용한 코딩 지침

좋은 하스켈 코딩 습관은 경험에서 많이 나오지만, 우리는 독자가 더 빨리 가독성있는 코드를 작성할 수 있는 일반적인 팁을 약간 가지고 있습니다.

“탭 VS 공백에 관한 언급” 장에서 언급했다시피, 하스켈 소스에서 절대 탭을 쓰지 마세요. 공백을 쓰세요.

만약 어떤 부분의 코드가 귀신처럼 영리한 것을 자랑스럽게 생각하는 자기 자신을 발견한다면, 일단 멈추고 한 달간 그 코드를 보지 않은 후에 자신이 다시 그 코드를 이해할 수 있을지 생각해 보세요.

타입이나 변수 이름을 합성어로 짓는 관습적인 방법은 “Camel case” 입니다. myVariableName이 예시가 됩니다. 이 스타일이 하스켈 코드에서 가장 보편적입니다. 명명법에 어떤 취향을 가졌던 간에, 표준이 아닌 관습을 따르면 당신의 하스켈 코드는 다른 읽는 사람의 눈에 다소 이상하게 보일 것입니다.

하스켈로 상당히 많은 시간 작업을 해보기 전까지, 작은 함수를 작성하기 전에 몇 분간 라이브러리 함수를 찾아보세요. 이건 특히 리스트나 Maybe, Either같은 널리 쓰는 타입에 해당됩니다. 만약 표준 라이브러리가 정확히 당신이 원하는 기능을 제공하지 않으면, 몇 가지 함수를 합성해서 원하는 결과를 얻을 수 있을 겁니다.

세 개나 네 개 이상의 연쇄된 합성 함수 파이프라인은 읽기 힘듭니다. 만약 그런 파이프라인이 있다면 let이나 where 블록으로 그걸 더 작은 조각으로 쪼개세요. 각각의 파이프라인 원소에 의미있는 이름을 붙인 후 다시 이어 붙이세요. 만약 원소에 적절한 이름이 떠오르지 않으면, 스스로 이게 무엇을 하는지 설명할 수 있는지 확인해보세요. 만약 대답이 “아니오”라면, 코드를 단순화하세요.

80열 이상 텍스트 에디터를 조절하기 쉽다고 해도, 이 너비는 아직 매우 일반적입니다. 더 긴 줄은 80열 짜리 텍스트 에디터 창에서 줄 바꿈되기 마련이고, 가독성을 심하게 해치게 됩니다. 한 줄에 최대 80글자까지만 들어가도록 다루면 당신이 한 줄에 휘갈기는 코드의 양도 제한합니다. 이건 각각의 줄이 덜 복잡해지고, 그 결과 더 이해하기 쉽도록 도와줄 겁니다.

일반적인 레이아웃 스타일

독자가 레이아웃 규칙을 제대로 지켜서 파싱에 애매함을 초래하지 않는 한 하스켈 구현은 당신을 야단치지 않을 겁니다. 그렇긴 하지만, 몇몇 레이아웃 패턴이 널리 쓰이고 있습니다.

in 키워드는 이어지는 표현식 바로 아래에 let 키워드 맞춰 곧바로 정렬됩니다.

-- file: ch09/Style.hs
tidyLet = let foo = undefined
              bar = foo * 2
          in undefined

in의 들여쓰기를 다르게 하거나 표현식 끝에 “매달리게” 해도 적법하지만, 다음 코드는 일반적으로 이상하게 보일 것입니다.

-- file: ch09/Style.hs
weirdLet = let foo = undefined
               bar = foo * 2
    in undefined

strangeLet = let foo = undefined
                 bar = foo * 2 in
    undefined

반면에 do는 줄 끝에 매다는 것이 다음 라인의 시작에 붙이는 것보다 더 일반적입니다.

-- file: ch09/Style.hs
commonDo = do
  something <- undefined
  return ()

-- not seen very often
rareDo =
  do something <- undefined
     return ()

중괄호와 세미콜론은 적법하지만 거의 쓰지 않습니다. 그 자체엔 아무 문제가 없습니다. 이것들은 단지 희소성만으로 코드를 어색하게 만듭니다. 이것들은 프로그램이 레이아웃 규칙을 구현하지 않고 하스켈 코드를 짜는 경우를 의도한 것이지, 사람이 쓰는 것을 의도하지 않았습니다.

-- file: ch09/Style.hs
unusualPunctuation =
    [ (x,y) | x <- [1..a], y <- [1..b] ] where {
                                           b = 7;
 a = 6 }

preferredLayout = [ (x,y) | x <- [1..a], y <- [1..b] ]
    where b = 7
          a = 6

만약 등식의 우변을 새 줄에서 시작한다면, 대개 그것이 정의하는 변수나 함수 이름보다 약간 더 들여쓰기 합니다.

-- file: ch09/Style.hs
normalIndent =
    undefined

strangeIndent =
                           undefined

들여쓰기에 쓰는 실제 공백 개수는 같은 파일 내에서도 자주 변합니다. 2, 3, 4개의 공백이 엇비슷하게 많이 쓰입니다. 공백 하나도 적법하지만, 잘 구분되지 않기에 잘못 읽기 쉽습니다.

where 구문을 들여쓸 땐, 그걸 잘 드러나도록 들여쓰는 게 가장 좋습니다.

-- file: ch09/Style.hs
goodWhere = take 5 lambdas
    where lambdas = []

alsoGood =
    take 5 lambdas
  where
    lambdas = []

badWhere =           -- legal, but ugly and hard to read
    take 5 lambdas
    where
    lambdas = []

연습문제

이 장의 파일을 찾는 코드가 학습 용도로 괜찮긴 하지만, 실제 시스템 프로그래밍 작업에선 별로 이상적이지 않습니다. 하스켈의 이식성 있는 입출력 라이브러리는 정보를 많이 노출하지 않아 궁금하고 복잡한 질의를 허용하지 않기 때문입니다.

  1. 이 장의 코드를 System.PosixSystem.Win32같은 네이티브 API를 사용해 독자의 플랫폼으로 포팅해보세요.

  2. 디렉토리 항목을 누가 소유하고 있는지 확인하는 기능을 독자의 코드에 추가해 보세요. 이 정보를 조건자에서도 쓸 수 있도록 만들어 보세요.