Bellroy에서 하스켈 사용을 확장하면서 정립해 온, 코드를 더 명확하게 표현하기 위한 몇 가지 작은 관용구를 소개한다.
URL: https://exploring-better-ways.bellroy.com/some-haskell-idioms-we-like.html
Bellroy에서 하스켈 사용을 규모 있게 늘려 오면서, “하우스 스타일” 또는 “엔지니어링 방언”을 기르는 일이 중요해졌습니다. 하우스 스타일은 당연히 하스켈에서 전통적으로 결정해야 하는 지점들—선호하는 라이브러리 세트와 언어 확장 선택—에 대한 결정을 포함합니다. 하지만 우리는 좋은 엔지니어링 방언이란 코드 표현 방식에 대해서도 의견을 형성하는 것이라고 믿습니다. 하스켈은 겉의 문법을 걷어내면 꽤 단순한 언어이고, 같은 아이디어도 아주 다양한 방식으로 표현될 수 있는데, 어떤 표현은 다른 것보다 훨씬 명확합니다. 이 글에서는 우리가 채택해 온 작은 관용구 몇 가지를 공유합니다. 모두가 새롭지는 않을 수 있지만, 충분히 가치가 있다고 생각해 문서로 남깁니다.
Monad에서 명시적으로 생성하기관용구: Maybe, Either e, [] 같은 구체적인 모나드에서 작업할 때는 pure를 호출하기보다 해당 타입의 데이터 생성자(data constructor)를 명시적으로 사용합니다.
예시:
haskell-- 이 예시는 우리 내부 DSL 중 하나에서 가져온 단순화된 버전입니다. -- | 항(term)을 위한 AST. data Term = TmBool Bool | TmNumber Rational | TmAnd Term Term -- 그 외 다른 생성자들 -- | 타입 열거형. data Type = TyBool | TyNumber -- 그 외 다른 것들 data TypedTerm = TypedTerm Type Term -- | 항의 타입 체크. -- -- 기대 타입이 주어지면 검사를 수행하고, 주어지지 않으면 -- 추론을 시도합니다. infer :: Maybe Type -> Term -> Either TypeError TypedTerm infer expectedTy term = case term of TmBool _ -> Right $ TypedTerm TyBool term TmNumber _ -> Right $ TypedTerm TyNumber term TmAnd l r -> do TypedTerm TyBool _ <- infer (Just TyBool) l TypedTerm TyBool _ <- infer (Just TyBool) r Right $ TypedTerm TyBool term -- ^^^^^ -- 여기서는 명시적으로 `Right`를 사용해, 이 `do` 블록이 -- 오직 데이터를 조립할 뿐 부수 효과를 수행하지 않는다는 점을 -- 독자에게 상기시킵니다.
논의: 하스켈의 Monad 타입클래스를 사용하는 주된 목적은 do 블록을 통해 “어떤 컨텍스트 안에서”의 연산을 순차적으로 기술할 수 있게 하는 것입니다. 하스켈 학습 자료는 IO를 가르치기 전에 많은 데이터 타입에 대한 Monad 인스턴스를 보여주곤 하지만, 보통 do 블록이 필요할 정도로 제어 흐름이 복잡한 것은 State나 IO 같은 “상태ful” 모나드입니다. 이 때문에 일부 독자들은 do 블록을 순차적이고 부수 효과가 있는 코드와 암묵적으로 연관 지어 생각하게 되며, 실제로 우리의 do 블록 대부분도 그런 유형입니다. 그래서 “성공 생성자”(예: Just, Right)를 명시적으로 적어 주면, 그렇지 않은 경우를 표시할 수 있고 여기서는 부수 효과가 없음을 드러낼 수 있습니다. 작은 차이지만, 특히 IO 같은 모나드의 바깥 do 블록이 있고 let이나 함수 인자에서 Maybe 또는 Either 값을 구성하는 상황에서는 명확성을 높이는 습관이 됩니다.
qualified import를 염두에 두고 모듈 설계하기관용구: 모듈을 qualified로 import했을 때 자연스럽게 읽히는 이름을 선택합니다. 다른 모듈의 유사한 이름과의 충돌을 피하려고 식별자 이름을 억지로 비틀지 않습니다.
예시:
haskellmodule Servant.Client.Handle where -- | 우리는 @Handle@이라고 부르고 @ServantHandle@ 같은 이름은 쓰지 않습니다. -- 그래야 핸들을 하나만 쓰는 코드는 더 간결하게 유지할 수 있습니다. -- 핸들 사이를 구분해야 하는 코드는 일관되게 (예를 들어) -- @Servant.Client.Handle@ 또는 @DynamoDB.Handle@처럼 참조할 수 있습니다. data Handle m = Handle {..} addCaching :: CacheConfig -> Handle m -> Handle m addCaching = undefined
haskell-- | 때로는 enum을 별도 모듈로 옮기는 것이 도움이 됩니다. -- 여기서는 @import qualified Bellroy.Shipping.Speed as Speed@ -- 라고 한 뒤 (예를 들어) @Speed.Regular@처럼 쓸 수 있습니다. module Bellroy.Shipping.Speed (Speed(..)) where data Speed = Regular | Expedited | Overnight
논의: 코드베이스가 커지면, 어떤 모듈이든 언젠가는 qualified로 import해야 할 때가 거의 필연적으로 옵니다. 이를 피하려고 식별자 이름에 문맥을 덧붙이는 시도(예: data ServantHandle m = ..., cacheServantHandle)는, 결국 qualified import가 필요해졌을 때 Servant.cacheServantHandle처럼 어색한 식별자를 만들게 됩니다. qualified import는 종종 “비틀린” 이름과 길이가 비슷하거나 더 짧은데도 훨씬 자연스럽게 읽힙니다.
이 관용구는 “핸들”이나 자료구조처럼 주요 타입 하나와, 그 타입에 주로 작동하는 함수들로 이루어진 모듈에 적용할 때 특히 효과적입니다.
where 절로 풀어내기관용구: 함수 매개변수 선언은 컴팩트하게 유지하고, 더 큰 패턴은 where 절 안에서 풀어냅니다. let으로도 같은 일을 할 수 있습니다.
예시:
haskell-- 큰 패턴 때문에 `=`의 좌변(LHS)을 여러 줄로 나누면, -- 본문이 어디서 시작하는지 파악하기 어렵습니다. someFunction (SomeRecord {field1, field2, field3}) anArg someOtherArg@(MorePatternNoise {..}) = body -- 대신 패턴 매칭을 `where`로 내립니다: someFunction' someRecord anArg someOtherArg = body where SomeRecord {field1, field2, field3} = someRecord MorePatternNoise {..} = someOtherArg
실제 코드에서 가져온 또 다른(단순화된) 예시:
haskellshipTo :: SalesOrder -> Either Error Logistics.Import.ShipTo shipTo SalesOrder{..} = do -- 생략: downstream이 원하는 형태로 데이터를 파싱하고 -- 가공하는 코드가 잔뜩 있습니다. Right Logistics.Import.ShipTo { name = fromMaybe fullName' orgName, attention = fullName' <$ orgName, address1, address2, address3, city = city', postalCode = postalCode', country = country', phone, email, consigneeTaxId = Nothing } where Address {city, country, postalCode, stateCode, stateName} = applyShippingAddressOverrides customerShippingAddress Attention {fullName, phone, email} = attention -- ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ -- `SalesOrder{..}`의 패턴 매칭을 더 부풀리는 대신, -- 여기서 `Attention`을 매칭합니다. 그러면 함수의 매개변수 선언을 -- 훑어보고 본문을 읽어 내려가기가 훨씬 쉬워집니다.
inverseMap을 적극적으로 사용하기관용구: (주입/조회)로 짝을 이루는 함수(우리에게 가장 흔한 예는 print/parse)를 작성할 때는 relude의 inverseMap 함수(또는 동등한 것)를 사용해 “조회” 측을 “주입” 측으로부터 도출합니다. inverseMap 함수는 도메인을 열거(enumerate)하여 입력 함수의 부분 역함수(partial inverse)를 계산합니다.
inverseMap :: (Bounded a, Enum a, Ord k) => (a -> k) -> k -> Maybe a
예시:
haskelldata ErrorCode = InsufficientCredits | InvalidParams | General deriving stock (Eq, Show, Generic, Enum, Bounded) renderErrorCode :: ErrorCode -> Text renderErrorCode = \case InsufficientCredits -> "insufficient_credits" InvalidParams -> "invalid_params" General -> "general" parseErrorCode :: Text -> Maybe ErrorCode parseErrorCode = inverseMap renderErrorCode -- ^^^^^^^^^^ -- `parseErrorCode`를 손으로 작성하는 일을 피합니다.
논의: print/parse 쌍에서 흔한 버그 원인 중 하나는 데이터 타입에 새 생성자를 추가하고도 파서를 업데이트하지 않는 것입니다. inverseMap으로 파서를 작성하면 이런 일이 불가능해집니다. 파서는 프린터로부터 도출되며, GHC가 프린터의 case 매칭이 완전한지(모든 생성자를 다루는지) 강제하기 때문입니다.
Hoogle에 따르면 inverseMap은 (훌륭한) 커스텀 프렐류드 relude에서만 제공되지만, 독립적으로 정의하는 것도 충분히 쉽습니다. 유일하게 미묘한 점은 중간 맵이 호출 간에 공유되도록 보장하는 것입니다.
haskell-- GHC가 이 함수가 인자 하나로 "완전히 적용(fully applied)"된 것처럼 -- 생각하도록 `k` 인자를 람다 안에 선언합니다. -- -- base-4.22 (GHC 9.14)에 들어오는 -- `enumerate :: (Bounded a, Enum a) => [a]`를 써도 됩니다. inverseMap :: forall a k. (Bounded a, Enum a, Ord k) => (a -> k) -> k -> Maybe a inverseMap f = let m = Map.fromList [(f a, a) | a <- [minBound .. maxBound] :: [a]] in \k -> Map.lookup k m
하스켈 블로그들은 종종 이 언어의 표현력 있는 타입 시스템이나 다른 “대표 기능”을 이용해 매우 인상적인 것들을 보여주지만, 더 소박한 기능들에서도(그리고 이름을 신중하게 선택하는 것만으로도) 많은 표현력이 나옵니다. 우리가 여기서 공유한 것 같은 관용구들은 블로그 포스트로 다뤄지기보다는, 코드 리뷰나 채팅, 포럼 글 같은 덜 공식적인 채널을 통해 퍼져나가는 경향이 있습니다. 우리는 이런 것들 중 일부를 드러내어 소개할 가치가 있다고 느꼈습니다. 코드 양은 크게 늘리지 않으면서도 명확성을 더해 주고, 우리가 사용하는 언어 기능에 제한을 강요하지도 않기 때문입니다(물론 그 부분에 대해서도 계속 대화가 진화하고 있습니다). 여러분도 유용하게 쓰길 바랍니다.