Haskell Style Guide - kowaink

Eunmin Kim·2022년 7월 19일
3
post-thumbnail

이 문서는 kowaink 스타일 가이드를 번역한 글입니다. 원문의 마지막 수정일은 2020년 11월 22일입니다.

하스켈 스타일 가이드

이 스타일 가이드는 Kowainik에서 사용합니다.

이 문서는 상업적 또는 오픈 소스에서 쓰고 있는 좋은 관례를 모아 작성한 것입니다.

스타일 가이드의 목표

이 문서는 사람들이 다양한 환경에서 자연스럽게 하스켈 코드를 작성할 수 있도록 하기 위해 만들었습니다. 다음과 같은 목표를 통해 생산성을 높이려고 합니다.

  1. 이해하기 쉬운 코드: 작성한 코드의 개념이 애매하거나 복잡하면 안 됩니다.
  2. 읽기 쉬운 코드: 작성한 코드는 기존에 작성한 코드처럼 보여야 합니다. 함수나 변수의 이름은 명확해야 합니다.
  3. 쓰기 쉬운 코드: 코드 형식에 대해 고민하는 시간을 줄여야 합니다.
  4. 유지 보수하기 쉬운 코드: 버전 관리 시스템을 사용할 때 실제 고치려고 하는 부분이 아닌 코드 형식에 대한 충돌을 줄여 유지 보수 부담을 줄입니다.

원래 있는 코드의 스타일을 따르기

원래 있는 코드의 스타일을 따르는 것을 기본 규칙으로 합니다. 만약 스타일을 고쳐야 한다면 스타일 변경 사항을 기능 수정과 따로 커밋해서 스타일이 변경되었다는 것을 알아보기 쉽게 해야 합니다.

들여 쓰기

들여 쓰기는 공백 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

이름 짓기

함수와 변수

  • 함수와 변수 이름은 소문자로 시작하는 캐멀 케이스(lowerCamelCase)를 사용합니다.
  • 데이터 타입, 타입 클래스와 생성자는 대문자로 시작하는 캐멀 케이스(UpperCamelCase)를 사용합니다.

새로운 연산자를 만들지 맙시다.

-- 생쥐 연산자는 무슨 뜻일까요? :thinking_suicide:
(~@@^>) :: Functor f => (a -> b) -> (a -> c -> d) -> (b -> f c) -> a -> f d

일반적인 값을 갖는 타입 변수를 제외하고 apar, 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

모듈 포맷팅 가이드라인

다음 도구를 사용해서 자동으로 모듈을 포맷팅하세요.

{-# LANGUAGE #-}

OPTIONS_GHCLANGUAGE 보다 앞에 적고 한 줄 띄어 구분해줍니다. LANGUAGE 는 한 줄에 하나씩 적고 알파벳 순서로 정렬합니다. 그리고 닫는 괄호는 가장 끝에 있는 괄호 기준으로 정렬합니다.

{-# OPTIONS_GHC -fno-warn-orphans #-}

{-# LANGUAGE ApplicativeDo       #-}
{-# LANGUAGE ScopedTypeVariables #-}
{-# LANGUAGE TypeApplications    #-}

Default 언어 확장

.cabal 파일에 자주 사용하는 언어 확장을 default-extensions 에 추가하면 좋습니다. 다음 언어 확장은 이 가이드에서 쓰는 언어 확장입니다.

ConstraintKinds
DeriveGeneric
DerivingStrategies
GeneralizedNewtypeDeriving
InstanceSigs
KindSignatures
LambdaCase
OverloadedStrings
RecordWildCards
ScopedTypeVariables
StandaloneDeriving
TupleSections
TypeApplications
ViewPatterns

Export 목록

export는 다음과 같은 규칙으로 포맷팅 합니다.

  1. 항상 명시적인 export 목록을 사용합니다.
  2. 들여쓰기는 공백 4칸입니다.
  3. export 목록에 섹션을 나누고 각 섹션은 Haddock 형식으로 이름을 적어줍니다.
  4. 클래스, 데이터 타입과 타입 별칭은 함수 섹션 앞에 적어줍니다.
module Map
    ( -- * Data type
      Map
    , Key
    , empty

      -- * Update
    , insert
    , insertWith
    , alter
    ) where

Import

항상 명시적인 import 또는 qualified 된 import를 사용합니다.

예외: 모듈 전체를 reexport 하는 경우는 제외

명시적인 import를 할지 qualified import를 할지는 여러분이 판단하세요. 하지만 다음과 같은 경우는 qualified import를 사용하세요.

  • 이름이 겹치는 경우
  • import 목록이 긴 경우
  • quailfied import를 사용하도록 라이브러리가 설계된 경우, 예) tomland 라이브러리

이 규칙은 라이브러리가 바뀌어도 안전하고 유지 보수하기 좋은 코드를 만들어 줍니다.

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는 다음과 같은 순서로 묶여 있어야 합니다.

  1. Hackage 패키지 중에 qualified 되어 있지 않은 import
  2. 현재 프로젝트에 qualified 되어 있지 않은 import
  3. Hackage 패키지 중에 qualified 되어 있는 import
  4. 현재 프로젝트에 qualified 되어 있는 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

엄격함(Strictness)

데이터 생성자에 있는 필드는 모두 엄격(strict) 해야 합니다. 따라서 명시적으로 엄격함을 표시하는 ! 를 붙이세요. 엄격하게 처리하면 공간 낭비나 필드 초기화를 빼먹었을 때 경고 대신 에러로 확인할 수 있습니다.

-- + 좋음
data Settings = Settings
    { settingsHasTravis  :: !Bool
    , settingsConfigPath :: !FilePath
    , settingsRetryCount :: !Int
    }

-- - 나쁨
data Settings = Settings
    { settingsHasTravis  :: Bool
    , settingsConfigPath :: FilePath
    , settingsRetryCount :: Int
    }

데이터 타입이 많은 모듈에는 StrictData 언어 확장을 사용해도 됩니다.

파생

파생을 하는 경우, 항상 deriving 구문에 파생 전략을 지정하세요. DerivingStrategies 언어 확장을 사용해서 파생할 타입 클래스를 어떤 방식으로 파생할 것인지 지정할 수 있습니다.

파생할 타입 클래스는 항상 괄호로 묶어주세요.

ShowEq는 모든 데이터 타입에 파생하세요.

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 구문

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 블록 안에서, 모나드 코드를 작성할 때는 thenelse 구문을 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 구문

가독성을 위해 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 구문

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 를 사용합니다.

순서 문제로 생기는 오류를 막기 위해서 RecordWildCardsApplicativeDo 확장을 함께 사용합니다.

GHC 옵션

다음 옵션으로 컴파일했을 때 컴파일러 경고가 없어야 합니다.

  • -Wall
  • -Wcompat
  • -Widentities
  • -Wincomplete-uni-patterns
  • -Wincomplete-record-updates
  • -Wredundant-constraints
  • -Wmissing-export-lists
  • -Wpartial-fields
  • -Wmissing-deriving-strategies
  • -Wunused-packages

컴파일 결과를 깨끗하게 하기 위해 -fhide-source-paths-freverse-errors 를 활성화합니다.

.hie 디렉토리 안에 .hie 파일을 만듭니다.

ghc-options:  -fwrite-ide-info
              -hiedir=.hie
profile
Functional Programmer @Constacts, Inc.

0개의 댓글