이 문서는 kowaink 스타일 가이드를 번역한 글입니다. 원문의 마지막 수정일은 2020년 11월 22일입니다.
이 스타일 가이드는 Kowainik에서 사용합니다.
이 문서는 상업적 또는 오픈 소스에서 쓰고 있는 좋은 관례를 모아 작성한 것입니다.
이 문서는 사람들이 다양한 환경에서 자연스럽게 하스켈 코드를 작성할 수 있도록 하기 위해 만들었습니다. 다음과 같은 목표를 통해 생산성을 높이려고 합니다.
원래 있는 코드의 스타일을 따르는 것을 기본 규칙으로 합니다. 만약 스타일을 고쳐야 한다면 스타일 변경 사항을 기능 수정과 따로 커밋해서 스타일이 변경되었다는 것을 알아보기 쉽게 해야 합니다.
들여 쓰기는 공백 4칸으로 합니다.
where
키워드는 공백 2칸을 들여 씁니다. where
키워드는 새로운 줄에 적습니다.
showDouble :: Double -> String
showDouble n
| isNaN n = "NaN"
| isInfinite n = "Infinity"
| otherwise = show n
greet :: IO ()
greet = do
putStrLn "What is your name?"
name <- getLine
putStrLn $ greeting name
where
greeting :: String -> String
greeting name = "Hey " ++ name ++ "!"
한 줄의 길이는 90자를 넘지 않아야 합니다. 90자를 넘으면 코드를 작게 쪼개거나 줄 바꿈 해서 여러 줄로 쓰세요.
줄 끝에 공백을 넣지 않습니다. (저장할 때 자동으로 지워주는 도구를 사용하면 좋습니다)
이항 연산자는 양쪽에 공백을 하나씩 넣습니다.
모듈 익스포트, 리스트, 튜플, 레코드 같은 곳에는 콤마를 앞에 쓰는 스타일(comma-leading style)을 사용합니다.
answers :: [Maybe Int]
answers =
[ Just 42
, Just 7
, Nothing
]
함수 선언을 할 때 한 줄 길이 제한을 넘으면 ::
, =>
, ->
같은 구분자 시작으로 줄 바꿈 해서 정렬합니다.
printQuestion
:: Show a
=> Text -- ^ Question text
-> [a] -- ^ List of available answers
-> IO ()
레코드 필드는 서로 다른 줄에 쓰고 콤마로 정렬합니다.
data Foo = Foo
{ fooBar :: Bar
, fooBaz :: Baz
, fooQuux :: Quux
} deriving stock (Eq, Show, Generic)
deriving anyclass (FromJSON, ToJSON)
합 타입의 모든 생성자는 서로 다른 줄에 쓰고 =
과 |
로 정렬합니다.
data TrafficLight
= Red
| Yellow
| Green
deriving stock (Show, Read, Eq, Ord, Enum, Bounded, Ix)
각 줄의 들여 쓰기는 윗 줄에 있는 식별자 길이와 상관없이 들여 쓰기 합니다.
함수 본문도 위의 규칙을 따라 작성하되 너무 이상하게 작성하지 않습니다.
-- + 좋음
createFoo = Foo
<$> veryLongBar
<*> veryLongBaz
-- - 나쁨
createFoo = Foo <$> veryLongBar
<*> veryLongBaz
-- - 별로
createFoo =
Foo -- 생성자를 별도의 라인으로 내려 쓸 필요는 없습니다
<$> veryLongBar
<*> veryLongBaz
기본적으로 한 줄로 적을 수 있다면 정렬을 신경 쓰지 않아도 됩니다. 불필요하게 짧은 을 여러 개 만들지 마세요.
함수 호출을 할 때 한 줄 길이 제한을 넘으면 한 줄에 인자 하나씩 쓰고 같은 단계로 정렬합니다.
veryLongProductionName
firstArgumentOfThisFunction
secondArgumentOfThisFunction
(DummyDatatype withDummyField1 andDummyField2)
lastArgumentOfThisFunction
새로운 연산자를 만들지 맙시다.
-- 생쥐 연산자는 무슨 뜻일까요? :thinking_suicide:
(~@@^>) :: Functor f => (a -> b) -> (a -> c -> d) -> (b -> f c) -> a -> f d
일반적인 값을 갖는 타입 변수를 제외하고 a
나 par
, g
와 같이 너무 짧아 의미를 알 수 없는 이름을 사용하지 않습니다.
-- + 좋음
mapSelect :: forall a . (a -> Bool) -> (a -> a) -> (a -> a) -> [a] -> [a]
mapSelect test ifTrue ifFalse = go
where
go :: [a] -> [a]
go [] = []
go (x:xs) = if test x
then ifTrue x : go xs
else ifFalse x : go xs
-- - 나쁨
mapSelect :: forall a . (a -> Bool) -> (a -> a) -> (a -> a) -> [a] -> [a]
mapSelect p f g = go
where
go :: [a] -> [a]
go [] = []
go (x:xs) = if p x
then f x : go xs
else g x : go xs
너무 긴 변수명을 사용하지 않습니다.
-- + 좋음
map :: (a -> b) -> [a] -> [b]
map _ [] = []
map f (x:xs) = f x : map f xs
-- - 나쁨
map :: (a -> b) -> [a] -> [b]
map _ [] = []
map function (firstElement:remainingList) =
function firstElement : map function remainingList
긴 이름에 있는 약자는 가독성의 이유로 대문자로 사용하지 않습니다. 예를 들면, TOMLException
대신 TomlException
을 사용합니다.
모듈에서 이미 유니코드 이름을 사용하는 경우에만 유니코드 이름을 씁니다. 유니코드 이름을 만들었다면 유니코드가 아닌 별칭(alias)을 만들어 주는 것이 좋습니다.
하스켈에서는 데이터 타입을 쉽게 만들 수 있습니다. 일반 타입(Int
, String
, Set Text
등) 대신 사용자 타입을(enum 이나 newtype
) 만드는 것이 좋습니다.
type
별칭은 일반 타입의 특별한 경우에만 씁니다.
-- + 좋음
data StateT s m a
type State s = StateT s Identity
-- - 나쁨
type Size = Int
data
또는 newtype
으로 생성자가 하나인 타입을 만들 때 생성자 이름은 타입 이름과 같아야 합니다.
data User = User
{ userId :: Int
, userName :: String
}
newtype
의 필드명은 un
으로 시작해야 합니다.
newtype Size = Size
{ unSize :: Int
}
newtype App a = App
{ unApp :: ReaderT Context IO a
}
레코드 데이터 타입의 필드명은 타입 이름으로 시작하는 것이 좋습니다.
-- + 좋음
data HealthReading = HealthReading
{ healthReadingDate :: UTCTime
, healthReadingMeasurement :: Double
}
타입명이 너무 길면 줄여 써도 됩니다.
-- + 허용함
data HealthReading = HealthReading
{ hrDate :: UTCTime
, hrMeasurement :: Double
}
라인 끝에 주석을 쓸 때는 공백 2칸을 띄고 씁니다.
newtype Measure = Measure
{ unMeasure :: Double -- ^ See how 2 spaces separate this comment
}
최상위에 정의한 함수와 함수 인자, 데이터 타입 필드에는 Haddock 문서를 작성합니다. 문서만 읽고도 충분히 쓸 수 있도록 자세한 정보를 적습니다.
여러 줄로 Haddock 문서를 쓸 때는 블록 주석({- |
과 -}
)을 사용합니다.
-- + 좋음
{- | Example of multi-line block comment which is very long
and doesn't fit single line.
-}
foo :: Int -> [a] -> [a]
-- + 역시 좋음
-- | Single-line short comment.
foo :: Int -> [a] -> [a]
-- ~ 나쁨
-- | Example of multi-line block comment which is very long
-- and doesn't fit single line.
foo :: Int -> [a] -> [a]
함수 인자, 데이터 생성자와 필드에 주석을 달 때는 줄 끝에 적는 Haddock 주석을 쓸 수 있습니다. 다만 한 줄의 길이 제한을 넘으면 안 됩니다. 만약 줄이 길어지면 블록 주석을 쓰고 다른 주석과 정렬을 맞추세요. 함수 인자, 데이터 생성자와 필드에 서로 다른 주석 형태를 사용하지 마세요.
-- + 좋음
{- | 'replicate' @n x@ returns list of length @n@ with @x@ as the value of
every element. This function is lazy in its returned value.
-}
replicate
:: Int -- ^ Length of returned list
-> a -- ^ Element to populate list
-> [a]
-- - 나쁨
{- | 'replicate' @n x@ returns list of length @n@ with @x@ as the value of
every element. This function is lazy in its returned value.
-}
replicate
:: Int -- ^ Length of returned list
{- | Element to populate list -}
-> a
-> [a]
주석에 타입 클래스 규칙이나 함수 사용 예제를 적어주면 좋습니다.
{- | The class of semigroups (types with an associative binary operation).
Instances should satisfy the associativity law:
* @x '<>' (y '<>' z) = (x '<>' y) '<>' z@
-}
class Semigroup a where
(<>) :: a -> a -> a
{- | The 'intersperse' function takes a character and places it
between the characters of a 'Text'.
>>> T.intersperse '.' "SHIELD"
"S.H.I.E.L.D"
-}
intersperse :: Char -> Text -> Text
다음 도구를 사용해서 자동으로 모듈을 포맷팅하세요.
OPTIONS_GHC
는 LANGUAGE
보다 앞에 적고 한 줄 띄어 구분해줍니다. LANGUAGE
는 한 줄에 하나씩 적고 알파벳 순서로 정렬합니다. 그리고 닫는 괄호는 가장 끝에 있는 괄호 기준으로 정렬합니다.
{-# OPTIONS_GHC -fno-warn-orphans #-}
{-# LANGUAGE ApplicativeDo #-}
{-# LANGUAGE ScopedTypeVariables #-}
{-# LANGUAGE TypeApplications #-}
.cabal
파일에 자주 사용하는 언어 확장을 default-extensions
에 추가하면 좋습니다. 다음 언어 확장은 이 가이드에서 쓰는 언어 확장입니다.
ConstraintKinds
DeriveGeneric
DerivingStrategies
GeneralizedNewtypeDeriving
InstanceSigs
KindSignatures
LambdaCase
OverloadedStrings
RecordWildCards
ScopedTypeVariables
StandaloneDeriving
TupleSections
TypeApplications
ViewPatterns
export는 다음과 같은 규칙으로 포맷팅 합니다.
module Map
( -- * Data type
Map
, Key
, empty
-- * Update
, insert
, insertWith
, alter
) where
항상 명시적인 import 또는 qualified 된 import를 사용합니다.
예외: 모듈 전체를 reexport 하는 경우는 제외
명시적인 import를 할지 qualified import를 할지는 여러분이 판단하세요. 하지만 다음과 같은 경우는 qualified import를 사용하세요.
이 규칙은 라이브러리가 바뀌어도 안전하고 유지 보수하기 좋은 코드를 만들어 줍니다.
qualified
import를 할 때는 적절한 이름을 사용하세요.
-- + 좋음
import qualified Data.Text as Text
import qualified Data.ByteString as BS
import qualified Toml
-- - 나쁨
import qualified GitHub as C
import qualified App.Server as Srv
import qualified App.Service as Svc
import는 다음과 같은 순서로 묶여 있어야 합니다.
각 import 묶음 사이는 한 줄 띄어 주세요.
코드가 시작하는 부분과 import 영역 사이에 2줄을 띄어 주세요.
각 import 그룹은 모듈명을 알파벳 순으로 정렬해주세요.
module MyProject.Foo
( Foo (..)
) where
import Control.Exception (catch, try)
import Data.Traversable (for)
import MyProject.Ansi (errorMessage, infoMessage)
import qualified Data.Aeson as Json
import qualified Data.Text as Text
import qualified MyProject.BigModule as Big
data Foo
...
데이터 타입 선언의 포맷팅 방법은 정렬 부분을 참고하세요.
레코드 타입에 데이터 생성자를 여러 개 만들지 않습니다.
-- - 나쁨
data Foo
= Bar { bar1 :: Int, bar2 :: Double }
| Baz { baz1 :: Int, baz2 :: Double, baz3 :: Text }
-- + 좋음
data Foo
= FooBar Bar
| FooBaz Baz
data Bar = Bar
{ bar1 :: Int
, bar2 :: Double
}
data Baz = Baz
{ baz1 :: Int
, baz2 :: Double
, baz3 :: Text
}
-- + 이것도 괜찮음
data Foo
= Bar Int Double
| Baz Int Double Text
데이터 생성자에 있는 필드는 모두 엄격(strict) 해야 합니다. 따라서 명시적으로 엄격함을 표시하는 !
를 붙이세요. 엄격하게 처리하면 공간 낭비나 필드 초기화를 빼먹었을 때 경고 대신 에러로 확인할 수 있습니다.
-- + 좋음
data Settings = Settings
{ settingsHasTravis :: !Bool
, settingsConfigPath :: !FilePath
, settingsRetryCount :: !Int
}
-- - 나쁨
data Settings = Settings
{ settingsHasTravis :: Bool
, settingsConfigPath :: FilePath
, settingsRetryCount :: Int
}
데이터 타입이 많은 모듈에는 StrictData 언어 확장을 사용해도 됩니다.
파생을 하는 경우, 항상 deriving 구문에 파생 전략을 지정하세요. DerivingStrategies 언어 확장을 사용해서 파생할 타입 클래스를 어떤 방식으로 파생할 것인지 지정할 수 있습니다.
파생할 타입 클래스는 항상 괄호로 묶어주세요.
Show
와 Eq
는 모든 데이터 타입에 파생하세요.
newtype
인 경우 newtype 파생 전략을 사용하는 것이 좋습니다.
{-# LANGUAGE DeriveAnyClass #-}
{-# LANGUAGE DerivingStrategies #-}
{-# LANGUAGE GeneralizedNewtypeDeriving #-}
newtype Id a = Id
{ unId :: Int
} deriving stock (Show, Generic)
deriving newtype (Eq, Ord, Hashable)
deriving anyclass (FromJSON, ToJSON)
최상위에 정의한 함수는 모두 타입 시그니처를 명시해야 합니다.
where
구문에 있는 모든 함수도 타입 시그니처를 명시해야 합니다. 타입 시그니처를 명시하면 숨어있는 에러를 막을 수 있습니다.
where
절에서 다형적인 타입을 사용하는 경우 ScopedTypeVariables
언어 확장이 필요할 수도 있습니다.
타입 시그니처에 forall
을 사용한다면 .
양 쪽으로 공백을 하나씩 두세요.
lookup :: forall a f . Typeable a => TypeRepMap f -> Maybe (f a)
타입 시그니처가 긴 경우 인자 타입을 한 줄씩 적고 정렬하세요.
sendEmail
:: forall env m
. ( MonadLog m
, MonadEmail m
, WithDb env m
)
=> Email
-> Subject
-> Body
-> Template
-> m ()
함수 본문의 인자가 길어지면 각 인자 이름을 다른 줄에 적습니다.
sendEmail
toEmail
subject@(Subject subj)
body
Template{..} -- default body variables
= do
<code goes here>
위와 같은 경우가 아니라면 =
는 함수 정의와 같은 줄에 적습니다.
연산자 우선순위는 함수 시그니처 바로 위에 적습니다.
-- | Flipped version of '<$>'.
infixl 1 <&>
(<&>) :: Functor f => f a -> (a -> b) -> f b
as <&> f = f <$> as
pragma는 함수 바로 다음에 적어 적용되도록 합니다.
-- | Lifted version of 'T.putStrLn'.
putTextLn :: MonadIO m => Text -> m ()
putTextLn = liftIO . Text.putStrLn
{-# INLINE putTextLn #-}
{-# SPECIALIZE putTextLn :: Text -> IO () #-}
데이터 타입 정의에 pragma를 쓰는 경우 타입 바로 전에 적어야 적용됩니다.
data TypeRepMap (f :: k -> Type) = TypeRepMap
{ fingerprintAs :: {-# UNPACK #-} !(PrimArray Word64)
, fingerprintBs :: {-# UNPACK #-} !(PrimArray Word64)
, trAnys :: {-# UNPACK #-} !(Array Any)
, trKeys :: {-# UNPACK #-} !(Array Any)
}
if-then-else보다 가드 구문을 사용합니다.
-- + 좋음
showParity :: Int -> Bool
showParity n
| even n = "even"
| otherwise = "odd"
-- - 좋지는 않음
showParity :: Int -> Bool
showParity n =
if even n
then "even"
else "odd"
if
, then
, else
는 같은 들여 쓰기로 정렬합니다.
digitOrNumber :: Int -> Text
digitOrNumber i =
if i >= 0 && i < 10
then "This is a digit"
else "This is a number"
가드 구문을 사용할 수 없는 do
블록 안에서, 모나드 코드를 작성할 때는 then
과 else
구문을 if
보다 한 단계 들여 씁니다.
choose
:: Text -- ^ Question text.
-> NonEmpty Text -- ^ List of available options.
-> IO Text -- ^ The chosen option.
choose question choices = do
printQuestion question choices
answer <- prompt
if null answer
then pure (head choices)
else pure answer
do
블록 밖에서는 if-then-else 구문을 일반 구문처럼 쓸 수 있습니다.
shiftInts :: [Int] -> [Int]
shiftInts = map $ \n -> if even n then n + 1 else n - 1
가독성을 위해 case 구문은 ->
화살표 안으로 정렬합니다.
-- + 좋음
firstOrDefault :: [a] -> a -> a
firstOrDefault list def = case list of
[] -> def
x:_ -> x
-- - Bad
foo :: IO ()
foo = getArgs >>= \case
[] -> do
putStrLn "No arguments provided"
runWithNoArgs
firstArg:secondArg:rest -> do
putStrLn $ "The first argument is " ++ firstArg
putStrLn $ "The second argument is " ++ secondArg
_ -> pure ()
함수의 마지막 인자를 패턴 매칭 할 때는 LambdaCase
확장을 사용합니다.
fromMaybe :: a -> Maybe a -> a
fromMaybe v = \case
Nothing -> v
Just x -> x
let
바인딩은 항상 새로운 줄에 작성합니다.
isLimitedBy :: Integer -> Natural -> Bool
isLimitedBy n limit =
let intLimit = toInteger limit
in n <= intLimit
do
블록 안에서 let
구문은 항상 앞에 적습니다. 순수 함수에서는 let
대신 where
를 사용합니다.
코드는 모듈로 분리합니다.
point-free 사용을 피합니다. point-free를 사용하지 않을 때 코드가 더 명확합니다.
-- + 좋음
foo :: Int -> a -> Int
foo n x = length $ replicate n x
-- - 나쁨
foo :: Int -> a -> Int
foo = (length . ) . replicate
return
대신 pure
를 사용합니다.
순서 문제로 생기는 오류를 막기 위해서 RecordWildCards
과 ApplicativeDo
확장을 함께 사용합니다.
다음 옵션으로 컴파일했을 때 컴파일러 경고가 없어야 합니다.
컴파일 결과를 깨끗하게 하기 위해 -fhide-source-paths
와 -freverse-errors
를 활성화합니다.
.hie
디렉토리 안에 .hie
파일을 만듭니다.
ghc-options: -fwrite-ide-info
-hiedir=.hie