더 나은 커스텀 Haskell 프렐류드 만들기
Haskell 프렐류드는 모든 Haskell 모듈에 기본으로 임포트되는데, 스스로 발등을 찍을 수 있는 방법을 끝없이 제공하며 제거할 수 없는 역사적 잔재도 담고 있습니다. 업스트림에서 고치기는 어렵지만, -XNoImplicitPrelude 언어 확장을 사용하면 프로젝트 수준에서 프렐류드를 완전히 제거하고 더 합리적인 기본값 집합으로 대체할 수 있습니다.
새 프렐류드를 만드는 데에는 두 가지 철학이 있습니다.
- 대형 차량 프렐류드 - 기본 타입과 클래스의 상당 부분을 대체하는 새로운 추상화를 도입하여 프렐류드의 결함 대부분을 고치고, 전반적으로 Haskell을 작성하는 방식을 재정의합니다. numeric-prelude 프렐류드를 참고하십시오.
- 소형 차량 프렐류드 - 기존 타입과 클래스를 기반으로 삼고 망가진 부분을 가려서 프렐류드의 고장 난 부분을 고칩니다. basic-prelude 프렐류드를 참고하십시오.
저는 대형 차량 방식을 지지하지 않습니다. 기본 프렐류드의 많은 부분이 이상적이지는 않지만 일을 해내기에는 충분합니다. 그리고 일반적으로 기본 Base와 프렐류드를 사용하는 나머지 생태계와의 상호 운용성은 매우 중요합니다.
이전에 업계에서 일하는 대규모 팀이라면 소형 차량 프렐류드를 직접 만드는 것이 좋다는 글을 쓴 적이 있지만, 무엇을 포함해야 하는지는 다소 모호하게 남겨 두었습니다. 당연히 회사마다 사정이 다르므로, 커스텀 프렐류드를 만들 때 바탕으로 삼을 수 있는 합리적인 기본값 집합이라고 제가 생각하는 것을 자세히 설명해 보려 합니다. 이것을 Protolude라고 부르겠습니다.
**[Git Project](https://github.com/sdiehl/protolude)**
모듈 구조
기본 모듈 구조는 모든 것을 몰아넣을 X라는 메인 모듈 재익스포트로 구성됩니다. 그리고 루트 모듈에서 재익스포트할 여러 잡다한 함수들이 있습니다. 여기에서 더 나아가 도메인별 함수를 담은 여러 유틸리티 모듈을 둡니다.
{-# LANGUAGE NoImplicitPrelude #-}
module Protolude (
module X,
identity,
bool,
(&),
uncons,
applyN,
print,
LText,
LByteString,
) where
import qualified Prelude as P
import qualified List as X
import qualified Show as X
import qualified Bool as X
import qualified Debug as X
import qualified Monad as X
import qualified Applicative as X
기본 타입
기본 타입(Int, Integer, Float, Char)은 관련 함수와 함께 통째로 재익스포트합니다.
-- Base types
import Data.Int as X
import Data.Bits as X
import Data.Word as X
import Data.Bool as X hiding (bool)
import Data.Char as X (Char)
import Data.Maybe as X hiding (fromJust)
import Data.Either as X
import Data.Complex as X
import Data.Function as X (
id
, const
, (.)
, flip
, fix
, on
)
이 연산자를 기본으로 제공하지 않는 GHC < 7.10을 위해 뒤집힌 적용 연산자를 정의합니다.
(&) :: a -> (a -> b) -> b
x & f = f x
항등 함수보다는 id라는 이름의 변수를 쓸 가능성이 훨씬 높으므로 id 함수의 이름을 identity로 바꿉니다. 이는 논란의 여지가 있는 부분이며, 이 변경이 맞을지는 경우에 따라 다를 수 있습니다.
identity :: a -> a
identity x = x
bool 함수(maybe와 either에 대응하는 컴비네이터)는 GHC 7.10에서 제공되지만, 이전 버전을 위해 재익스포트합니다.
bool :: a -> a -> Bool -> a
bool f t b = if b then t else f
uncons 함수는 기본으로 제공되지 않지만, 리스트를 Maybe 튜플로 풀어 주며, 헤드가 존재하면 튜플의 첫 번째 매개변수에 담습니다.
uncons :: [a] -> Maybe (a, [a])
uncons [] = Nothing
uncons (x:xs) = Just (x, xs)
applyN 함수는 함수와 횟수를 받아 그 함수를 n번 적용합니다.
applyN :: Int -> (a -> a) -> a -> a
applyN n f = X.foldr (.) id (X.replicate n f)
그리고 분기하는 모나드 로직을 다루기 위한 다양한 불리언 컴비네이터를 재익스포트합니다.
whenM :: Monad m => m Bool -> m () -> m ()
whenM p m =
p >>= flip when m
unlessM :: Monad m => m Bool -> m () -> m ()
unlessM p m =
p >>= flip unless m
ifM :: Monad m => m Bool -> m a -> m a -> m a
ifM p x y = p >>= \b -> if b then x else y
guardM :: MonadPlus m => m Bool -> m ()
guardM f = guard =<< f
Safe
Safe는 기본으로 제공되는 여러 부분 함수(head, tail)의 Maybe 버전을 제공합니다. 결과를 Maybe로 감싸는 것이 올바른 접근이라는 데 널리 공감대가 형성되어 있으며, Haskell을 오늘날 설계한다면 이러한 부분 함수는 존재하지 않을 것입니다.
-- Maybe'ized version of partial functions
import Safe as X (
headMay,
initMay,
tailMay
)
디버깅
부분 함수가 포함된 코드나 각종 치명적인 프로그램 로직에 유용하므로 다양한 디버깅 명령과 트랩 명령은 여전히 제공됩니다. 다만 이제는 이러한 명령을 코드에 남겨 두면 컴파일러가 경고를 내보내며, 이 경고에는 해당 명령이 사용된 위치 정보가 함께 표시됩니다. 이 동작은 프로덕션 코드와 디버깅 코드 사이에서 전환할 수 있습니다.
{-# WARNING undefined "'undefined' remains in code" #-}
undefined :: a
undefined = P.undefined
{-# WARNING error "'error' remains in code" #-}
error :: P.String -> a
error = P.error
{-# WARNING trace "'trace' remains in code" #-}
trace :: P.String -> a -> a
trace = T.trace
{-# WARNING traceShow "'traceShow' remains in code" #-}
traceShow :: P.Show a => a -> a
traceShow a = T.trace (P.show a) a
{-# WARNING traceShowM "'traceShowM' remains in code" #-}
traceShowM :: (P.Show a, P.Monad m) => a -> m ()
traceShowM a = T.traceM (P.show a)
{-# WARNING traceM "'traceM' remains in code" #-}
traceM :: P.Monad m => P.String -> m ()
traceM = T.traceM
{-# WARNING traceIO "'traceIO' remains in code" #-}
traceIO :: P.String -> P.IO ()
traceIO = T.traceIO
{-# WARNING notImplemented "'notImplemented' remains in code" #-}
notImplemented :: a
notImplemented = P.error "Not implemented"
Either
Maybe와 Either 사이를 변환하는 여러 either 조합자가 제공되어야 마땅하지만 실제로는 제공되지 않으며, 이러한 조합은 네 가지가 있습니다. Left 쪽 값에 대해 mempty를 쓸 수 있는 모노이드 인스턴스가 있는 maybe를 처리하는 것도 꽤 흔한 작업입니다.
leftToMaybe :: Either l r -> Maybe l
leftToMaybe = either Just (const Nothing)
rightToMaybe :: Either l r -> Maybe r
rightToMaybe = either (const Nothing) Just
maybeToRight :: l -> Maybe r -> Either l r
maybeToRight l = maybe (Left l) Right
maybeToLeft :: r -> Maybe l -> Either l r
maybeToLeft r = maybe (Right r) Left
maybeToEither :: Monoid b => (a -> b) -> Maybe a -> b
maybeToEither = maybe mempty
List
Foldable과 Traversable로 일반화된 함수를 제외한 여러 리스트 함수는 전부 그대로 내보냅니다. head는 foldr로 작성된 전역 함수로 다시 내보냅니다. 프렐류드에서 의도치 않게 이차 시간 복잡도를 갖는 ordNub 함수는 내부적으로 해시맵을 사용하는 로그 시간 변형으로 대체됩니다.
head :: (Foldable f) => f a -> Maybe a
head = foldr (\x _ -> return x) Nothing
sortOn :: (Ord o) => (a -> o) -> [a] -> [a]
sortOn = sortBy . comparing
-- O(n * log n)
ordNub :: (Ord a) => [a] -> [a]
ordNub l = go Set.empty l
where
go _ [] = []
go s (x:xs) =
if x `Set.member` s
then go s xs
else x : go (Set.insert x s) xs
모나드
Core 모나드 클래스와 함수, 조합자는 전부 그대로 다시 내보냅니다. mapM 같은 변형은 Control.Monad 대신 Data.Traversable에서 제공합니다.
module Monad (
Monad(..)
, MonadPlus(..)
, (=<<)
, (>=>)
, (<=<)
, forever
, join
, mfilter
, filterM
, mapAndUnzipM
, zipWithM
, zipWithM_
, foldM
, foldM_
, replicateM
, replicateM_
, concatMapM
, guard
, when
, unless
, liftM
, liftM2
, liftM3
, liftM4
, liftM5
, liftM'
, liftM2'
, ap
, (<$!>)
) where
import Prelude (concat, seq)
import Control.Monad
concatMapM :: (Monad m) => (a -> m [b]) -> [a] -> m [b]
concatMapM f xs = liftM concat (mapM f xs)
liftM은 예상치 못한 지연 평가가 흔히 발생하는 원인이므로 엄격한 버전도 기본으로 제공합니다.
liftM' :: Monad m => (a -> b) -> m a -> m b
liftM' = (<$!>)
{-# INLINE liftM' #-}
liftM2' :: (Monad m) => (a -> b -> c) -> m a -> m b -> m c
liftM2' f a b = do
x <- a
y <- b
let z = f x y
z `seq` return z
{-# INLINE liftM2' #-}
Applicative
applicative 모듈의 Core 타입은 전부 그대로 다시 내보냅니다. 7.8과 달리 Applicative 클래스가 기본으로 스코프에 들어 있으며, 요즘 시대에는 당연히 그래야 합니다.
-- Applicatives
import Control.Applicative as X (
Applicative(..)
, Alternative(..)
, Const(..)
, ZipList(..)
, (<**>)
, liftA
, liftA2
, liftA3
, optional
)
타입 클래스
순서와 수치 타입 계층을 위한 Core GHC 타입 클래스는 전부 그대로 다시 내보냅니다. 또한 Traversable과 Foldable을 스코프에 가져오면서, 일반적으로 피해야 하는 몇몇 부분 함수는 가립니다. Semiring도 요즘에는 충분히 흔하게 쓰이므로 암묵적으로 스코프에 들어 있어야 합니다.
-- Base typeclasses
import Data.Eq as X
import Data.Ord as X
import Data.Monoid as X
import Data.Traversable as X
import Data.Foldable as X hiding (
foldr1
, foldl1
, maximum
, maximumBy
, minimum
, minimumBy
)
import Data.Semiring as X
import Data.Functor.Identity as X
import Data.Functor as X (
Functor(..)
, ($>)
, (<$>)
, void
)
Deepseq
Deepseq는 여러 사용 사례에서 파생해야 하는 중요한 타입 클래스인 경우가 많으므로, deepseq 라이브러리와 그 함수들을 가져옵니다.
-- Deepseq
import Control.DeepSeq as X (
NFData(..)
, ($!!)
, deepseq
, force
)
자료 구조
GHC가 제공하는 Core 자료 구조는 다시 내보내지만, List의 경우 부분 함수는 반드시 가리고 연산자는 더 일반적인 인터페이스로 제공합니다.
-- Data structures
import Data.Tuple as X
import Data.List as X (
splitAt
, break
, intercalate
, isPrefixOf
, drop
, filter
, reverse
, replicate
)
containers 라이브러리들은 모두 서로 겹치는 함수(조회, insert 등)를 제공하므로, 시그니처에서 사용할 수 있도록 이러한 자료 구조의 타입만 익스포트합니다. 그러면 모듈 수준에서는 보통 import qualified Data.Map as Map과 같은 한정 임포트로 이들을 가져오게 됩니다.
import Data.Map as X (Map)
import Data.Set as X (Set)
import Data.Sequence as X (Seq)
import Data.IntMap as X (IntMap)
import Data.IntSet as X (IntSet)
모나드 트랜스포머
기본 mtl 트랜스포머 스택, 특히 StateT, ReaderT, ExceptT는 현대 Haskell에서 어디에나 쓰입니다. 이 모듈들의 함수를 모두 내보내지는 않고, 흔히 쓰이는 트랜스포머의 사용 사례 대부분을 감당할 수 있을 만큼만 스코프에 가져옵니다.
-- Monad transformers
import Control.Monad.State as X (
MonadState,
State,
StateT,
put,
get,
gets,
modify,
withState,
runStateT,
execStateT,
evalStateT,
)
import Control.Monad.Reader as X (
MonadReader,
Reader,
ReaderT,
ask,
asks,
local,
runReader,
runReaderT,
)
import Control.Monad.Except as X (
MonadError,
Except,
ExceptT,
throwError,
catchError,
runExcept,
runExceptT,
)
import Control.Monad.Trans as X (
MonadIO,
lift,
liftIO,
)
내장 타입
몇몇 기본 타입(IO, Show)은 무엇을 하든 필요하므로 당연히 가져옵니다. Exts는 FFI 작업을 위한 다양한 포인터 타입과 함께 Constraint(제약) 타입도 제공하는데, 이 타입은 -XConstraintKinds나 더 고급 타입 패밀리 기법을 사용할 때 꽤 흔히 쓰입니다.
-- Base GHC types
import GHC.IO as X (IO)
import GHC.Num as X
import GHC.Real as X
import GHC.Float as X
import GHC.Show as X
import GHC.Exts as X (
Constraint
, Ptr
, FunPtr
, the
)
제네릭
제네릭 또한 현대 Haskell에서는 어디에나 쓰이므로, -XDeriveGeneric을 사용하고 Generic 클래스의 기본 인스턴스를 구현할 수 있도록 여러 타입 클래스와 타입 동의어를 제공해야 합니다.
-- Generics
import GHC.Generics (
Generic(..)
, Rep
, K1(..)
, M1(..)
, U1(..)
, V1
, D1
, C1
, S1
, (:+:)
, (:*:)
, NoSelector
, Rec0
, Par0
, Constructor(..)
, Selector(..)
, Arity(..)
, Fixity(..)
)
문자열 타입
Haskell의 문자열은 고통이 끝없이 반복되는 거대한 프랙탈입니다. 공정한 세상이라면 우리는 대부분 Text를 쓰고 네트워크를 다뤄야 할 때만 ByteString을 썼겠지만, 우리는 그런 세상에 살고 있지 않으며 Haskell 생태계의 상당 부분은 여전히 극도로 비효율적인 Char 연결 리스트를 사용합니다.
논란의 여지가 있지만, 우리는 Lazy 텍스트 타입을 타입 동의어 LText로 재익스포트하고, 다중 매개변수 타입 클래스를 사용해 임의의 두 문자열 타입 사이를 자동으로 변환해 주는 string-conv 라이브러리 함수 toS를 제공합니다. 문자열을 많이 다루는 프로그램이라면 여러 Text 및 ByteString 빌더 클래스를 재익스포트하는 것도 고려해 볼 만합니다.
-- ByteString
import qualified Data.ByteString.Lazy
import qualified Data.ByteString as X (ByteString)
-- Text
import Data.Text as X (Text)
import qualified Data.Text.Lazy
import qualified Data.Text.IO
import Text.Printf as X (printf)
import Data.Text.Lazy (
toStrict
, fromStrict
)
import Data.String.Conv as X (
strConv
, toS
, toSL
, Leniency(..)
)
-- Printf
import Text.Printf as Exports (
PrintfArg
, printf
, hPrintf
)
type LText = Data.Text.Lazy.Text
type LByteString = Data.ByteString.Lazy.ByteString
IO
IO는 사실상 당연히 필요하므로, 여러 IO 연산과 저수준 명령줄 옵션 및 파일 핸들러 함수를 재익스포트합니다.
-- IO
import System.Exit as X
import System.Environment as X (getArgs)
import System.IO as X (
Handle
, hClose
)
공정한 세상이라면 문자열 하나를 출력하려고 모든 망할 모듈마다 보일러플레이트 import를 넣지 않아도 되는 제네릭 putStr이 있었겠지만, 안타깝게도 우리는 그런 세상에 살고 있지 않습니다. 하지만 일반적인 텍스트 표현에 대해 이를 처리하는 기본 클래스를 구현한 다음, 모두 같은 인터페이스를 제공하는 네 개의 라이브러리 대신 그 클래스만 익스포트할 수 있습니다.
class Print a where
putStr :: MonadIO m => a -> m ()
putStrLn :: MonadIO m => a -> m ()
instance Print T.Text where
putStr = liftIO . T.putStr
putStrLn = liftIO . T.putStrLn
instance Print TL.Text where
putStr = liftIO . TL.putStr
putStrLn = liftIO . TL.putStrLn
instance Print BS.ByteString where
putStr = liftIO . BS.putStr
putStrLn = liftIO . BS.putStrLn
instance Print BL.ByteString where
putStr = liftIO . BL.putStr
putStrLn = liftIO . BL.putStrLn
instance Print [Char] where
putStr = liftIO . Prelude.putStr
putStrLn = liftIO . Prelude.putStrLn
-- For forcing type inference
putText :: MonadIO m => T.Text -> m ()
putText = putStrLn
{-# SPECIALIZE putText :: T.Text -> IO () #-}
putLText :: MonadIO m => TL.Text -> m ()
putLText = putStrLn
{-# SPECIALIZE putLText :: TL.Text -> IO () #-}
위의 함수들과 마찬가지로 print와 여러 문자열 IO 연산을 제네릭 MonadIO 인스턴스로 자동 리프트하여 트랜스포머 스택 안에 넣을 수 있게 합니다.
print :: (X.MonadIO m, P.Show a) => a -> m ()
print = liftIO . P.print
동시성
기본 스레드 프리미티브는 충분히 흔하게 쓰이므로 암묵적으로 스코프에 들여와야 합니다. async 라이브러리는 동시성 로직을 다루는 데 권장되는 방법을 제공하므로 이 역시 통째로 익스포트합니다.
-- ST
import Control.Monad.ST as ST
-- Concurrency and Parallelism
import Control.Exception as X
import Control.Concurrent as X
import Control.Concurrent.Async as X
이제 cabal 파일에 다음을 추가하기만 하면 됩니다.
default-extensions:
NoImplicitPrelude
그런 다음 각 모듈에서 여러분의 합리적인 프렐류드를 포함하십시오. 이 선택을 원하지 않는 모듈에서는 import Prelude로 기본 프렐류드를 명시적으로 가져오기만 하면 모듈 단위로 빠져나갈 수 있습니다.
이것이 제가 여러 cabal 파일로 구성된 대규모 프로젝트에서 사용하는 기본 원형 프렐류드입니다. 이 중 일부 선택은 상황에 따라 다르게 느껴질 수 있지만, 저는 이것이 최소한 좋은 출발점이 되어 줄 만큼 꽤 합리적이고 실용적인 기초 함수 및 타입 모음이라고 생각합니다.