여러 타입들이 공통된 속성이 있다면, 클래스로 타입을 열어 두자

Posted on August 14, 2021

아래처럼 methodA, methodB를 만들다 보니, 나중에 약간 다른 Config타입을 받을 일이 생긴다고 가정해 봅시다.

-- ModuleA.hs
module ModuleA ( methodA) where
data Config = Config { pCommon :: Int, pSpecA :: Int }

methodA :: Config -> Int
methodA conf = pCommon conf

-- ModuleB.hs
module ModuleB ( methodB ) where
data Config = Config { pCommon :: Int, pSpecB :: Int }

methodB :: Config -> Int
methodB conf = pCommon conf + 1

-- ModuleC.hs
module ModuleC () where
import ModuleA
import ModuleB

data Config = Config { pCommon :: Int, pSpecC :: Int }

conf = Config 10 20

run :: Config -> Int
run = methodA + methodB

컴파일 하면 여러 Config가 섞여 있어 에러가 납니다.

ModuleC.hs:14:7: error:
Couldn't match typeConfig
                     with ‘ModuleA.Config
      NB:ModuleA.Config’ is defined at ModuleA.hs:6:1-54
Config’ is defined at ModuleC.hs:9:1-54
      Expected type: Config -> Int
        Actual type: ModuleA.Config -> Int
In the expression: methodA + methodB
      In an equation for ‘run’: run = methodA + methodB
   |
14 | run = methodA + methodB
   | 

나중에 다른 Config 타입에서 쓸 일이 있는 메소드는 methodA :: Config -> Int 처럼 구체 타입을 지정하면 안됩니다. 다른 타입에서 쓸 일이 있는 함수는 GHC의 타입 추론에 맡기는 것 말고 방법이 없습니다. methodA :: c -> Int 이런식으로 열어 놔야 합니다.

공통으로 가지고 있는 속성을 클래스로 옮기면 다음처럼 쓸 수 있습니다.

-- ModuleA.hs
module ModuleA ( methodA) where
import ConfigCommon
--data Config = Config { pSpecA :: Int }
--instance ConfigCommon Config where
--    pCommon cfg = 1
methodA :: ConfigCommon c => c -> Int
methodA conf = pCommon conf -- 어떤 pCommon이 쓰일지 여기선 아직 알 수 없다.


-- ModuleB.hs
module ModuleB ( methodB ) where
--data Config = Config { pSpecB :: Int }
--instance ConfigCommon Config where
--    pCommon cfg = 2
methodB :: ConfigCommon c => c -> Int
methodB conf = pCommon conf + 1

-- ConfigCommon.hs
-- Config의 구체적인 모습은 모르지만, 일단 pCommon 속성을 쓴다는 걸 알려주기 위해 필요합니다.
{-#LANGUAGE AllowAmbiguousTypes #-}
module ConfigCommon ( ConfigCommon(..)) where
class ConfigCommon c where
  pCommon :: c -> Int

-- ModuleC.hs
module ModuleC () where
import ModuleA
import ModuleB
import ConfigCommon

data Config = Config { pSpecC :: Int }

instance ConfigCommon Config where
    pCommon cfg = 10

conf = Config 20

run :: Config -> Int
run conf = methodA conf + methodB conf + pSpecC conf

-- run :: ConfigCommon c => c -> Int
-- run conf = methodA conf + methodB conf + pSpecC conf
-- pSpecC를 쓰는 함수라면 이렇게 쓰지 못합니다.
-- ConfigCommon c => 제약만으론 pSpecC가 뭔지 알려 줄 도리가 없습니다.
-- pSpecC와 methodA, methodB 가 무엇인지 모두 알려 줄 방법은 
-- 여기서 쓰이는 Config 타입 하나로 타입을 닫아야 합니다.
> run conf
41

원했던 방식으로 동작은 하지만, 매번 Config를 설정할 때 ConfigCommon의 인스턴스로 만들어야 합니다. 불편해서 다른 패턴이 없을까 고민 중인데, 타입을 열어두는 것 말고 딱히 찾아지거나, 떠오르는 아이디어가 없습니다.

문제를 정리하면, 각 메소드가 정의된 파일에서 Config란 데이터를 참조하는데, 각 모듈에 있는 Config들이 조금씩 다릅니다. 물론 Config마다 각각인 속성은 건드리지 않고, 공통된 속성만 건드리는데, 이 메소드들을 다른 컨텍스트 안에서 모아서 쓰려면 어떻게 해야 할까요?

레코드 필드가 특별한 요소가 아니라, 그저 함수들을 모아만 놓은 것으로 본다면, 그 중 다른 Config들과 공통된 함수(여기선 필드field)들은 클래스 메소드로 보내버리고, 공통되지 않는 필드들만 남겨 놓은 상태라 볼 수 있습니다. 레코드 문법이 하나의 객체안에 속성들을 끈끈하게 모아 놓은게 아니라, 그저 여기 저기 흩어져 있는 함수들의 이름을 모아, 대표 이름을 붙여 놓은 것입니다. 그래서 레코드안의 필드들이 다른 레코드의 필드와 이름이 겹칠 수 없습니다. 필드들도 그냥 보통의 함수와 다를 게 없습니다. - 딱 이해하기 좋은 표현은 아닌 것 같긴 합니다.

Github 계정이 없는 분은 메일로 보내주세요. lionhairdino at gmail.com