타입 클래스를 버리고 Backpack 모듈/시그니처로 대체해 보는 대안적 우주를 탐험한다. Functor·Monad 같은 표준 타입클래스를 시그니처로 치환하고, IO·State 등으로 효과를 구현/교체하며, 장단점과 빌드 설정을 예제와 함께 살펴본다.

죽음(13)은 문자 그대로의 육체적 죽음이 아니라 중대한 변화, 변형, 끝맺음을 상징한다.
들판에서 풀을 뜯는 Number를 본 적이 있는가? 나무 위에서 지저귀는 Functor는? 없다? 그것은 거짓말이기 때문이다. 서민을 억누르려는 부르주아가 퍼뜨린 거짓말. 하지만 나는 말한다, 이제 더는 속임수에 눌려 살지 않겠다고! 형제자매여, 모이라. 가치의 체계를 만들자. 값들은 더 이상 타입 클래스에 구속되지 않고, 시그니처로서 모듈에 합쳐지게 하자. 동지들이여, 배낭(Backpack)을 열자.
여기서는 타입 클래스를 잊고 Backpack 모듈 시스템을 택한 대체 우주를 탐험한다. 결과는 Haskell 안의 OCaml처럼 보이게 된다. Functor부터 시작해 보자.
signature Death.Functor.Signature (Functor , map) where
import Prelude ()
data Functor a
map :: (a -> b) -> Functor a -> Functor b
이건 functor😼이고, 이것도 functor🐫다. 고양이😼 쪽 Functor는 한 범주를 다른 범주로 삽입하는 범주론적 functor다. 여기서 범주는 집합과 함수들의 범주2로, 타입은 집합에, 함수는 음, 함수에 해당한다. 동시에 낙타🐫 쪽은 OCaml의 모듈 펑터로서, data 키워드는 시그니처에 구멍을 하나 뚫어 주고, 나중에 그 구멍을 실제 타입으로 메울 수 있게 한다.1
Prelude는 가려야 한다. base의 Functor 타입클래스가 기본으로 임포트되기 때문이다. 방금 소개한 시그니처는 일반 모듈처럼 임포트해 쓸 수 있다. 시그니처가 하는 일은 단지 "나중에" 그에 맞는 모듈을 제공하리라고 컴파일러에 약속하는 것뿐이다. 우리 functor는 지금 당장 써도 된다. 예를 들어, 같은 impl 패키지에 Functor용 보조 모듈을 만들어 유틸리티를 제공할 수 있다:
module Death.Functor (module X , (<$>) , (<$)) where
import Death.Functor.Signature as X
import Prelude (const)
(<$>) :: (a -> b) -> Functor a -> Functor b
(<$>) = map
(<$) :: b -> Functor a -> Functor b
(<$) b = map (const b)
모듈이 시그니처를 구현하기만 하면, 그에 의존하는 것들은 "공짜"로 따라온다. 시그니처에 의존하는 코드는 추상적이다. 가능한 한 더 많은 코드를 추상적으로 만들고 싶으니, 시그니처를 작게 유지하는 편이 좋다. 이는 타입클래스 정의를 작게 유지해 인스턴스를 작게 만들고, 타입클래스에 의존하는 코드를 추상적으로 유지하고자 하는 것과 비슷하다. 이제 모듈의 힘으로 Maybe 데이터타입에 대한 우리 Functor 시그니처의 "인스턴스"를 만들어 보자:
module Death.Functor.Maybe (Functor , map) where
import Prelude(Maybe(..), ($))
type Functor = Maybe
map :: (a -> b) -> Functor a -> Functor b
map fab = \case
Just x -> Just $ fab x
Nothing -> Nothing
여기서, 컴파일러가 이 인스턴스를 알아보도록 하려면 Cabal에서 해야 할 일이 적지 않다는 점을 짚고 넘어가야 한다. 시그니처가 들어 있는 메인 라이브러리의 Cabal 설정은 대략 다음과 같다:
library
signatures:
Death.Functor.Signature
exposed-modules:
Death.Functor
hs-source-dirs:
src/sig
그다음 구현 라이브러리는 다음과 비슷하다:
library impl
exposed-modules:
Death.Functor.Maybe
hs-source-dirs:
src/impl
Cabal이 시그니처에 해당하는 모듈을 못 찾으면, 이런 식으로 에러를 낸다:
cabal build
Resolving dependencies...
Error:
Non-library component has unfilled requirements: Death.Functor
In the stanza 'executable exe'
In the inplace package 'death-1.0.0'
즉, 최종 실행 파일을 빌드할 때 모든 시그니처에 구현이 있음을 보장해 준다. 이 에러를 해결하려면 그것을 어떤 구현으로든 실현(realize)해야 한다. 클라이언트는 그 구현이 무엇인지는 신경 쓰지 않는다:
library app
exposed-modules:
Death
hs-source-dirs:
src/app
mixins:
death (Death.Functor.Signature as Death.Functor.Maybe)
mixin 필드에서 우리가 말하는 바는 Death.Functor.Signature 임포트를 실제로는 Death.Functor.Maybe라고 부르라는 것이다. 즉, 컴파일러가 Death.Functor.Signature를 만나면 언제나 Maybe 구현을 쓰게 된다. 또는 구현 패키지의 모듈 이름을 아예 시그니처와 동일하게 지을 수도 있었다. 난 나중에야 알았다. 다만 이렇게 이름을 바꾸어 주면 같은 패키지에서 여러 모듈 시그니처를 구현할 수 있다. 그래서 한 모듈 안에서 여러 functor를 동시에 쓸 수 있다. 예를 들면, 여기서는 List, Maybe, IO Functor를 한 모듈에서 모두 사용한다:
{-# LANGUAGE RebindableSyntax #-}
module Death (main) where
import Maybe.Functor
import Maybe.Applicative
import Maybe.Monad
import qualified List.Functor as LF
import qualified IO.Monad as IO
import Death.BusinessLogic(business)
commonFaith :: Maybe String
commonFaith = Just "no longer constrained by deceit"
marchOfValues :: [Int]
marchOfValues = [4, 3, 2]
main :: IO ()
main = (print @(Functor String) $ do
cryOfUprising <- (\x -> ("rise up" <> x)) <$> pure "brothers and sisters"
chorusOfTruth <- commonFaith
pure $ cryOfUprising <> " against the lies" <> chorusOfTruth
<> show ((+1) LF.<$> marchOfValues)
) IO.>> business
RebindableSyntax를 켜서, do가 사용할 >>=를 스코프 내에 있는 것으로 쓰라고 GHC에 알린다. 여기서는 Maybe.Monad의 >>=다. 시그니처를 만족하기만 하면 컴파일러는 만족한다 😼. do는 Monad와 아무 상관이 없다! 누가 당신에게 거짓말을 한 거지?
아, 그 비즈니스 말인데. 그래, 이런 식으로 상업용 코드를 짤 수는 없다; 끔찍하다. 우리에겐 효과 시스템이 필요하다.3 다행히, 다재다능한 원 트릭 포니가 있다. 이것이 우리의 비즈니스 코드다:
business :: Functor ()
business = do
writeLine "file name:"
systemOfValues <- readLine
writeLine "file content:"
truthOfTheFields <- readLine
writeLine "writing file..."
writeFile systemOfValues truthOfTheFields
writeLine "reading it again to make sure its ISO 42038 compliant"
uprisingAgainstDeceit <- readFile systemOfValues
writeLine uprisingAgainstDeceit
이 시점에서 Functor가 무엇인지는 모른다. 실현된 구현에서는 IO이길 원한다. 테스트에서는 예컨대 state monad로 바꾸어 설정할 수 있다. 그러면 모든 일이 메모리 안에서 제대로 일어나는지4 확인할 수 있고, 믿을 수 없는 파일 시스템에 의존하지 않아도 된다.
비즈니스 로직 구현에서 거꾸로 출발해, 이를 받쳐 줄 시그니처들을 정의하자:
signature Death.Effects.FileSystem (readFile , writeFile) where
import Prelude(String, FilePath)
import Death.Functor.Signature
readFile :: FilePath -> Functor String
writeFile :: FilePath -> String -> Functor ()
사실 지금 와서 보니, 우리 FileSystem 효과 위에 Prelude를 리네임드 임포트해서 실현된 구현을 바로 끼워 넣을 수도 있었다. 대신, 별도 모듈을 만들었다:
module Death.Effects.FileSystem
( readFile
, writeFile
)
where
import Prelude(IO, readFile, writeFile)
망치를 오래 바라보면 모든 것이 못처럼 보이기 마련! 사실 state 구현이 더 흥미롭다:
module Death.Effects.FileSystem
( readFile
, writeFile
)
where
import Prelude(String, FilePath, ($))
import Death.Functor.Signature
import Data.Map qualified as Map
import Data.Maybe(fromMaybe)
import Death.Functor.State
readFile :: FilePath -> Functor String
readFile path = Functor $
\state -> (state, fromMaybe "" $ Map.lookup path (fileSystem state))
writeFile :: FilePath -> String -> Functor ()
writeFile path contents = Functor $
\state -> (state {fileSystem = Map.insert path contents (fileSystem state)}, ())
재미있는 점은, 재미없다는 것이다. 맥락을 모르는 분을 위해 말하자면, 이것은 사실상 state monad를 1:1로 베낀 것이다. 화려한 타입 따위 전혀 없다. 아무 일도 일어나지 않는다. 이런 걸 지적하고 있는 내가 바보가 된 기분이다. 5
여기서는 Cabal 파일에서 같은 이름 트릭을 써서 모듈과 각 시그니처를 일치시켰다. 좀 더 깔끔해진다:
library app
exposed-modules:
Death
hs-source-dirs:
src/app
build-depends:
death:impl,
death:effects,
death:effects-io,
death:effects-app,
death:effects-app에는 실제 "비즈니스" 로직을 선언하고, death:effects의 시그니처들을 death:effects-io의 모듈로 통일(unify)한다. 이상한 mixin DSL을 쓰는 것보다 훨씬 쓰기 좋다. 어렵지는 않은데, Cabal 에러가 서식과 출력 우선순위가 나빠서 그렇다. 중요한 에러가 수십 줄의 관련 없는 출력에 묻히는 경우가 있다
테스트 스위트는 대신 state monad 구현을 쓴다:
test-suite unit
main-is: Test.hs
hs-source-dirs:
test
build-depends:
death:effects-app,
death:effects-state,
그리고 예상대로 동작한다:
unitTests :: TestTree
unitTests = testGroup "Unit tests"
[
testCase "run business logic main" $ do
let (result, ()) = unFunctor Death.business $ State {
lineInput = "awesomeFile",
linesOutput = [],
fileSystem = mempty
}
result @?= State {
lineInput = "awesomeFile",
linesOutput = ["awesomeFile","reading it again to make sure its ISO 42038 compliant","writing file...","file content:","file name:"],
fileSystem = Map.fromList[("awesomeFile", "awesomeFile")]
}
보라, 우리는 아무것도 하지 않고 효과 시스템 대체물을 만들어 냈다. 우리가 한 것이라고는 기술적 극단주의의 입장을 취하고, 가만히 지켜본 것뿐이다. 처음 그 입장을 취하자 글은 저절로 써졌다. 만물은 흐른다. 독자여, 미안하다. 속였다! 아무것도 하지 않는 것이야말로, 볼 줄 아는 이들에게 보여 주고 싶었던 진짜 가치 체계였다. 이 글은 Backpack에 관한 게 아니다.7
우리의 Backpack 효과 시스템이 제공하는 것은 무엇인가? 화려한 타입이 없으니 에러 메시지를 풀기 쉽다. 다만 그 대신 Cabal 에러 메시지가 늘어나는데, 이건 개선의 여지가 있다.6 역량(capability)으로 IO 전체를 지원하고, continuations까지 포함한다.8 단형(monorphic) 효과는 MTL에 비해 에러 메시지가 더 좋아진다. MTL은 다형성 때문에 엉뚱한 위치를 가리키는 오류가 나오곤 한다. 컴파일 시간 특성도 다르고, 잠재적으로 더 빠를 수 있다. 예를 들어 모든 구현을 병렬로 컴파일할 수 있다. 다만 패키지를 추가로 쪼개야 하는 강제가 그와 어긋나기도 한다. 런타임 속도는 IO만큼 빠르다. 구현만 제공하면, 기반 모나드는 무엇이든 설정할 수 있기 때문이다. 물론 효과 시스템에서 속도가 그리 중요하다고는 생각하지 않는다. 프로덕션에서는 효과가 병목일 때 CPU 바운드인 경우가 드물다. 하지만 모든 것을 메모리에서 수행하는 테스트 스위트에서는 병목이 될 수 있다.
이 글에서는 표준 타입클래스도 함께 치환했다. 솔직히 말해 이로써 얻는 게 많다고는 생각하지 않는다. 이제는 어떤 Functor나 Monad를 임포트하는지 명시적으로 밝혀야 하고, 같은 모듈 안에서 서로 다른 Monad에 대해 do 표기를 동시에 쓸 수도 없다. Backpack은 사실 시그니처에 제약(constraint)을 정의할 수 있다. 그러니까 Backpack을 쓰려면 내가 한 것처럼 표준 타입클래스를 일일이 대체할 필요는 없다. 그럼에도 이렇게 한 이유는 초기의 간단한 실험을 해 보기 위해서였다. 게다가 충격과 공포를 위해 가짜 우상들을 때려 부숴야 한다고 느끼기도 했다.
누군가 Backpack을 더 진지하게 받아들여, 그 위에 효과 시스템을 구축하고 기본 시그니처와 구현을 잔뜩 제공해 주면 정말 좋겠다. Backpack은 이미 GHC와 Cabal에 구워져 있어 실험하기 쉽다. 타입 클래스에 죽음을💀! 배낭을 열자!
그러니까, OCaml 모듈 펑터🐫가 범주 펑터😼인가? 1급 모듈을 고려하면 그렇다고 생각한다! Haskell 시그니처도 어떤 의미의 범주일 수 있다. 병합이 가능하니 모노이드다. 모듈 시그니처는 타입 도입의 집합일 뿐인 것처럼 보인다. 병합은 그 합집합이 된다. 우리는 이 1급성(first class-ness)이 없다. 값을 다루듯 모듈을 전달하고 싶은데, 모양은 레코드와 비슷하다. 하지만 진지하게 말해 이 글의 범위는 이미 폭발했다. 이 방황은 독자 여러분의 연습 문제로 남겨 둔다.↩
물론 Hask는 범주가 아니다. 하지만 털끝만큼의 구분을 즐기지 않는다면 또 그렇기도 하다.↩
농담이다. 효과 시스템이 꼭 필요하다고 생각하지 않는다! 여기서는 그나마 덜 끔찍한 것을 정의할 뿐이다.↩
이게 정말 좋은 생각인지는 모르겠다. 테스트 속도가 조금 빨라지는 대신 보일러플레이트가 많아 보인다. 하지만 효과 시스템의 합리적인 용처로 내가 상상할 수 있는 건 이것뿐이다.↩
내가 이걸 시도한 유일한 사람일 거라고 꽤 확신한다. 컴파일러 버그를 여럿 마주쳤기 때문이다. 내가 미친 걸까?↩
Cabal이 에러를 숨기는 예시
$ cabal build
> Build profile: -w ghc-9.8.4 -O1
> In order, the following will be built (use -v for more details):
> - death-1.0.0 (lib) (file src/sig/Death/Base.hsig changed)
> - death-1.0.0 (lib:effects) (file src/effects/Death/Functor/Signature.hs changed)
> - death-1.0.0 (lib with Death.Applicative.Signature=death-1.0.0-inplace-impl:Death.Applicative.List, Death.Base=death-1.0.0-inplace-impl:Death.Functor.List, Death.Functor.Signature=death-1.0.0-inplace-impl:Death.Functor.List, Death.Monad.Signature=death-1.0.0-inplace-impl:Death.Monad.List) (first run)
> - death-1.0.0 (lib with Death.Applicative.Signature=death-1.0.0-inplace-impl:Death.Applicative.Maybe, Death.Base=death-1.0.0-inplace-impl:Death.Functor.Maybe, Death.Functor.Signature=death-1.0.0-inplace-impl:Death.Functor.Maybe, Death.Monad.Signature=death-1.0.0-inplace-impl:Death.Monad.Maybe) (first run)
> - death-1.0.0 (lib:effects-app) (configuration changed)
> - death-1.0.0 (lib:app) (configuration changed)
> - death-1.0.0 (exe:exe) (configuration changed)
> Preprocessing library 'effects' for death-1.0.0...
> Preprocessing library for death-1.0.0...
> Error: [Cabal-7554]
> can't find source for Death/Functor/Signature in src/effects, dist-newstyle/build/x86_64-linux/ghc-9.8.4/death-1.0.0/l/effects/build/effects/autogen, dist-newstyle/build/x86_64-linux/ghc-9.8.4/death-1.0.0/l/effects/build/global-autogen
>
> Building library instantiated with
> Death.Applicative.Signature = > Death.Base = > Death.Functor.Signature = > Death.Monad.Signature = > for death-1.0.0... > [1 of 8] Compiling Death.Base[sig] ( src/sig/Death/Base.hsig, nothing ) [Source file changed] > [2 of 8] Compiling Death.Functor.Signature[sig] ( src/sig/Death/Functor/Signature.hsig, nothing ) > [3 of 8] Compiling Death.Functor ( src/sig/Death/Functor.hs, nothing ) [Death.Base changed] > [4 of 8] Compiling Death.Applicative.Signature[sig] ( src/sig/Death/Applicative/Signature.hsig, nothing ) > [5 of 8] Compiling Death.Applicative ( src/sig/Death/Applicative.hs, nothing ) > [6 of 8] Compiling Death.Monad.Signature[sig] ( src/sig/Death/Monad/Signature.hsig, nothing ) [Death.Functor.Signature changed] > [7 of 8] Compiling Death.Monad ( src/sig/Death/Monad.hs, nothing ) [Death.Base changed] >> src/sig/Death/Functor/Signature.hsig:7:1: warning: [GHC-66111] [-Wunused-imports] > The import of ‘Prelude’ is redundant > except perhaps to import instances from ‘Prelude’ > To import instances alone, use: import Prelude() > | > 7 | import Prelude (Show(..)) > | ^^^^^^^^^^^^^^^^^^^^^^^^^ > Error: [Cabal-7125] > Failed to build lib:effects from death-1.0.0 (which is required by lib:effects-app from death-1.0.0).
이 문장은 실수가 아니다. 정말 진심이다! ↩
그냥 언급해 두는 것이다. effectfull이 continuation을 문제적이라고 적어놨길래.↩