타입클래스와 모듈 비교
요약
본 글은 OCaml의 모듈 시스템과 Haskell/Rust 등의 타입클래스 개념을 비교하며, 두 메커니즘의 근본적인 차이점과 유사점을 분석합니다. 핵심적으로 타입클래스는 ad-hoc 다형성을 제공하여 여러 타입에 걸쳐 동일한 연산자를 재사용하는 데 중점을 둡니다.
핵심 포인트
- 타입클래스의 주 목표는 ad-hoc 다형성(연산자 오버로딩)을 제공하는 것입니다.
- 모듈 시스템의 주 목표는 의존성 주입, 캡슐화 등을 통한 모듈형 추상화를 가능하게 합니다.
- 두 메커니즘 모두 '인터페이스 지정'이라는 공통점을 가지지만, 근본적인 설계 목적이 다릅니다.
- 타입클래스는 연산자 사용의 의미론적 일관성을 높이는 데 기여합니다.
오랫동안 저는 모듈 시스템(OCaml 같은 언어에서 볼 수 있듯이)과 타입클래스(Haskell이나 Rust 같은 언어에서 볼 수 있듯이)의 차이점과 유사점에 대해 많은 지속적인 혼란을 느꼈다는 것을 알아차렸습니다. 이것은 그 혼란을 해소하려는 시도입니다.
간단한 답변
t입클래스(typeclasses)를 언어 구성 요소로 사용하는 주된 목표는 ad-hoc 다형성을 제공하는 것입니다. 이는 연산자 오버로딩 또는 타입 기반 디스패치라고도 알려져 있습니다. ad-hoc 다형성의 주요 목적은 소규모 프로그래밍의 편의 기능으로서, 여러 타입에 걸쳐 동일한 식별자를 재사용할 수 있게 해줍니다. 예를 들어, 저는 정수(integers)와 float 벡터 모두에 대한 덧셈을 나타내기 위해 + 기호를 사용하고 싶을 수 있습니다 (중요하게도, 벡터 타입은 임의의 라이브러리에서 정의될 수 있으므로, 이 동작을 언어에 하드코딩할 수는 없습니다).
모듈 시스템(module systems)을 언어 구성 요소로 사용하는 주된 목표는 모듈형 추상화를 제공하는 것입니다. 이 기능의 일부 측면은 의존성 주입(dependency injection), 캡슐화(encapsulation), *정보 은닉(information hiding)*과 같은 이름으로 광범위한 소프트웨어 산업에서 불립니다. 모듈형 추상화의 주요 목적은 프로그램 분해를 명시적으로 표현할 수 있게 함으로써 더 효율적인 대규모 프로그래밍을 가능하게 하는 것입니다. 이는 프로그램을 재귀적으로 구성 요소 조각들로 나눌 수 있는 조합적 방식을 제공합니다.
제가 혼란을 야기한다고 생각하는 주요 요인들은 다음과 같습니다:
-
역사적으로, 프로그래밍 언어는 둘 중 하나를 선택하는 경향이 있었습니다.
-
그들은 일부 근본적인 메커니즘을 공유합니다.
-
인터페이스를 지정하는 방법 - 그리고 코드의 특정 묶음이 그 인터페이스에 부합함을 보여주는 방법
-
인터페이스를 지정하는 방법
-
때로는 다른 것을 사용하여 어느 정도 에뮬레이션할 수 있습니다 (즉, 모듈형 추상화를 제공하기 위해 타입클래스를 사용하고, ad-hoc 다형성을 제공하기 위해 모듈을 사용하는 식입니다).
하지만, 에뮬레이션이 많은 경우에 가능하더라도 일반적으로 최적은 아닙니다.
왜 그리고 무엇인가: 타입클래스
타입클래스(typeclass)의 근본적인 동기 부여 아이디어는, 만약 어떤 기호를 오버로드(overload)할 것이라면, 그것은 '도덕적으로' 같은 의미를 가져야 한다는 것입니다. 예를 들어, 기호 +는 덧셈이라는 추상적인 개념을 나타내며, 우리는 서로 다른 타입에 대해 다양한 구현을 가질 수 있습니다. 이는 반면 C++의 경우처럼 <<가 타입에 따라 비트 시프트(bitshift)를 의미할 수도 있고 스트림 리디렉션(stream redirection)을 의미할 수도 있는 것과 대조적입니다. 후자는 아무런 공통점이 없습니다. 여기서 목표는, 프로그램의 의미를 이해하기 쉽게 만드는 것입니다 (무계획적인 C++ 방식에 비해), 동시에 기호의 수를 줄여 인지 부하(cognitive overhead)를 낮추는 것입니다. 우리는 +나 >>=와 같이 추상적인 구조에서 작동하는 몇 가지 기호를 학습하고, 그것들을 다양한 구체적인 구조 전반에 걸쳐 사용할 수 있습니다.
이것을 좀 더 진지하게 다루고 싶다면, 의미론(semantics)도 고려해야 합니다. 어떻게 하면 +가 실제로 추상적인 덧셈 연산을 나타내도록 보장할까요? 글쎄요, 우리는 타입클래스에 법칙(laws)을 추가할 수 있습니다. 예를 들어, 덧셈은 (이것이 float에서는 참이 아니지만) 만족해야 한다고 말하고, 그런 다음 속성 테스트(property test)나 증명 증인(proof witness)을 사용하여 새로운 구현이 실제로 그 법칙을 만족하는지 확인할 수 있습니다. 일반적으로 각 타입클래스는 의미론에 대한 설명과 함께 묶일 수 있으며, 이상적으로는 테스트 스위트(test suite)나 증명 의무(proof obligation)의 형태로 기계 검사될 수 있습니다.
참고로, 여기 Haskell의 Num 타입클래스 정의가 있습니다. ghc에서 가져온 것입니다. 이것이 몇 가지 법칙을 제공한다는 점에 주목하세요. 하지만 이 경우 그것들은 단지 관례적일 뿐입니다. 예를 들어, 부동 소수점 곱셈은 결합법칙(associative)을 만족하지 않지만 여전히 Num 인스턴스를 가집니다.
-- | Basic numeric class.
--
-- The Haskell Report defines no laws for 'Num'. However, @('+')@ and @('*')@ are
...
왜 그리고 무엇: 모듈(Modules)
모듈의 목적은 프로그램의 일부에 대해 인체공학적으로 추상화할 수 있도록 하는 것입니다. 이를 위해 우리는 프로그램을 부분("모듈", "구조체")으로 분할하고, 이들이 어떻게 결합되는지 명시적으로 지정합니다. 중요한 점은 각 모듈이 자신 일부를 숨김으로써(캡슐화(encapsulation)!) 상호작용하는 다른 모듈들로부터 전체 프로그램에 대해 추론해야 하는 총 노력을 줄일 수 있다는 것입니다.
이는 출력물에는 좋지만, 입력물에 대해서도 추상화를 고려해야 합니다. 이를 위해 우리는 그 자체로 모듈이 아니지만, 다른 모듈(들)과 인스턴스화될 때 모듈을 생성하는 매개변수화된 모듈(역사적인 이유로 펑터(functors)라고 불리는 경우가 많습니다)을 만들 수 있습니다. 어떤 모듈들과 함께 인스턴스화할 수 있을까요? 특정 인터페이스를 준수하는 모든 모듈입니다 (문헌에서는 종종 시그니처(signatures)라고 불립니다)!
다시 말하지만, 우리가 이 주제에 대해 진지하게 접근한다면, 관례법이나 테스트 스위트, 증명 의무와 같은 의미론을 인터페이스에 첨부할 수도 있습니다. 결국, 프로그램을 모듈식 분해로 구조화하는 가장 일반적인 이유 중 하나는 테스트의 용이성을 높이기 위함입니다. 중요한 인터페이스를 식별하고 해당 인터페이스에서 테스트하는 것은 저렴한 비용으로 많은 커버리지(coverage)를 얻을 수 있는 매우 효과적인 방법입니다.
여러 기능을 사용하는 예시를 살펴보겠습니다. 저는 unison 파일 동기화 프로젝트에서 다음 코드를 가져와 명확성을 위해 약간 수정했습니다.
먼저, 표준 라이브러리의 Map.S 시그니처를 따르지만 키 타입을 String으로 특수화한 StringMap이라는 타입이 있음을 선언합니다. 이는 stdlib가 노출하는 인터페이스에 의존하는 모든 코드가 우리의 사용자 정의 맵을 사용할 수 있으며, 심지어 모든 호출 지점(callsites)이 올바른 키 타입을 가지고 있는지 확인할 수 있다는 것을 의미합니다.
다음으로 매개변수화된 모듈이 있습니다. 높은 수준의 아이디어는 이것이 파일 감시를 위한 플랫폼별 API에 대해 추상화하는 인터페이스라는 것입니다.
- 우리는 매개변수화된 모듈
Watcher를 선언하고, - 이 모듈은 모듈
WatchDescriptor를 받으며, - 이는
watch를 정의합니다.
핸들 타입(handle type)
-
그리고 나중에 선언된 제한적인 파일 디스크립터와 유사한 API를 지원하는 모듈을 반환하는 타입을 정의합니다.
-
우리는 워처(watcher)를 다루기 위한 API를 노출하는 시그니처
WatchBackend를 선언합니다. 또한 매개변수화된 모듈인Run도 있습니다. -
이는
Watcher를 실행하는 데 사용될 수 있습니다. -
WatchBackend를 구현하는 모듈을 사용하여
(매개변수화된 모듈은 빈 시그니처를 가진 무언가를 반환하므로, 그 부수 효과만을 위해 사용될 수 있습니다.)
-
이는 실행하는 데 사용될 수 있습니다.
-
우리는 다음과 같은 시그니처를 선언합니다:
module StringMap : Map.S with type key = string
module Watcher (WatchDescriptor : sig type watch end) : sig
type t
...
방법: 타입클래스를 이용한 모듈화
타입클래스(Typeclasses)는 종종 추상화를 위해 사용됩니다. 예를 들어, 여기 제 프로젝트의 간단한 코드가 있습니다:
pub trait FileSystem {
/// 경로가 존재하는지 확인합니다
fn exists(&self, path: &Path) -> bool;
...
저는 파일 시스템 작업이 많이 필요하지 않기 때문에 필요한 것만 선언하며, 이는 해당 인터페이스에서 테스트하기에 용이하게 만듭니다.
대신 모듈을 사용했을 때 무엇을 잃었을까요?
- 우리가 무언가를 분해하는 방식이 구성적(compositional)이지 않습니다. 예를 들어, 여기서
read_dir가 특화된Item타입을 가진Iterator트레이트를 만족하는 무언가를 반환할 수 있다고 말했습니다. 하지만 “이 메서드가 내가 방금 만든 특정 시그니처와 일치하는 것을 반환한다”는 것을 표현할 수 없습니다 (즉, 인라인 인터페이스는 존재하지 않습니다). 모든 인터페이스는 어떤 타입에 묶여야 하며(이는 스코프 내에 존재함), 다른 인터페이스 안에 중첩될 수 없습니다. - 이제 이 인터페이스를 사용하려는 모든 함수는 트레이트 바운드(trait bound)를 얻게 되는데, 이는 구체적인 버전보다 더 장황합니다. 극도로 긴 함수 시그니처가 나오면 짜증나고 노이즈가 많습니다. 모듈을 사용하면 바운드를 모듈 레벨에 둘 수 있고 각 함수는 의존성을 명시적으로 선언할 필요가 없습니다 (Rust는impl로 이를 어느 정도 구현합니다)
블록은 만들 수 있지만 컴포저블하지도 않습니다). 타입클래스는 어드혹 폴리모피즘(ad-hoc polymorphism)을 위해 설계되었기 때문에, 인스턴스 검색은 보통 구현의 일부가 되며, 이는 컴파일 시간에 비용이 많이 들 수 있습니다. (예: rust에서
foo.bar()
는 먼저 foo의 타입을 추론한 다음, 간접적으로 impl foo를 할 수 있는 모든 것을 살펴보고,
그다음 bar에 대해 가능한 구현이 무엇인지 확인해야 합니다.)
일반적으로 저는 이 접근 방식이 제가 '함수 집합(set of functions)' 접근법이라고 부르는 프로그램 분해에 기여한다고 생각합니다. 타입클래스 세계관은 전역적인 타입 데이터베이스가 존재하며, 함수들은 스코핑에 따라 이 데이터베이스의 다른 부분들을 볼 수 있지만, 궁극적으로 프로그램은 단지 함수의 집합일 뿐입니다. 각 함수는 독립적이며, 함수 수준에서 추론해야 합니다.
모듈(Modules)은 프로그램 분해에 대해 더 풍부한 사고방식을 제공합니다. 우리는 경계를 함수 단위로만 그릴 수 있는 것이 아닙니다. 한 번에 프로그램의 더 큰 부분을 추론할 수 있습니다. 앞서 언급된 비유로 말하자면, 모듈은 우리가 한 번에 생각할 수 있는 프로그램의 자명하지 않은 부분집합(nontrivial subsets)을 의미합니다.
방법: 모듈을 사용한 어드혹 폴리모피즘
사실 반대 방향으로 갈 것도 있습니다.
많은 ML 프로그래머들은
모든 산술 연산에 네임스페이스를 지정할 필요 없이 산술을 수행하는 것.
let average x y =
let open Int64 in
(x + y) / of_int 2;;
...
우리가 정말로 임의 다형성(ad-hoc polymorphism)을 원한다고 가정해 봅시다. 생각해 보면, 전통적인 타입클래스(typeclass)는 다음과 같은 방식으로 모듈(module)의 관점에서 표현될 수 있습니다:
- 우리는 시그니처(signature)에 대한 전역 데이터베이스를 가지고 있습니다.
- 우리는 타입(type)에 대한 전역 데이터베이스를 가지고 있습니다.
- 우리는 전역 데이터베이스의 모듈을 가지며, 각 모듈은 특정 타입과 연결되어 있습니다 (즉, 모든 모듈은 타입을 가지지만, 하나의 타입이 여러 개의 모듈을 가질 수 있습니다).
- 또한, 각 시그니처는 자신에게 부합하는 모든 모듈과 그에 따른 타입을 알고 있습니다.
그리고 우리가 시그니처에서 무언가를 사용할 때마다, 우리는 타입을 추론하고 이를 사용하여 구체적인 모듈을 조회합니다.
이론적으로, 여러분은 타입클래스 우선 접근 방식(typeclass-first approach)과 거의 동일한 방식으로 임의 다형성을 구현할 수 있으며, 이 경우 사용 편의성 측면에서 실질적인 손실은 없습니다. 이것이 기본적으로 OCaml의 Modular Implicits 제안이 시사하는 바입니다.
Scala 3 역시 'Contextual Abstraction'이라고 불리는 것을 지원하는데, 이는 본질적으로 위에서 설명한 방식을 수행하는 방법입니다. 더 전통적인 타입클래스 시스템과 주요 차이점은 *일관성(coherence)*을 요구하지 않는다는 점인데, 이 부분은 이번 글의 범위를 벗어납니다.
모듈과 타입클래스는 함께?
네! Haskell에는 모두가 사용하는 타입클래스 시스템 외에도 아무도 사용하지 않는 Backpack 모듈 시스템이 있습니다. 이론적으로 볼 때, 이는 모듈 추상화와 임의 다형성 모두에 대한 좋은 지원을 가지고 있습니다. 불행하게도, Backpack은 생태계에 너무 늦게 등장하여 큰 채택률을 보지 못했습니다.
저는 Rocq(구 Coq) 역시 독립적인 모듈 시스템과 타입클래스 시스템을 가지고 있다고 생각합니다.
Rust는 트레이트(trait) 시스템과는 별개의 모듈 시스템을 가지고 있지만, 완전한 모듈형 프로그래밍을 지원하기에는 너무 약합니다. 이 모듈 시스템은 캡슐화만 지원할 뿐이며 추상적인 모듈 시그니처를 지정하는 방법이 없으므로, 매개변수화된(parametrized) 모듈을 가질 수 없습니다.
Elixir는 모듈 시스템을 가지고 있으며, 이를 “behaviours”라고 부르는 시그니처와 또한 “protocols”라고 부르는 타입클래스 스타일의 ad-hoc 다형성(polymorphism)도 갖추고 있습니다. 게다가 Elixir는 점진적 타이핑(gradual typing)을 지원하여 모듈이 특정 behaviour에 적합한지 확인할 수 있게 해줍니다. 하지만 현재로서는 어떤 시그니처를 준수하는 모든 모듈을 입력으로 받고 싶다고 선언하는 것이 불가능합니다. 따라서 매개변수화된 모듈은 가지고 있지만, 타입 검사(typechecked)는 되지 않습니다.
결론
*모듈적 추상화(modular abstraction)*와 ad-hoc 다형성의 차이점을 기억해 주십시오. 이 둘 모두 프로그래밍 언어에서 유용한 기능일 수 있으며, 각각 다른 목적을 가집니다: 하나는 대규모 프로그래밍을 위해, 다른 하나는 소규모 프로그래밍을 위해 사용됩니다. 타입클래스와 모듈은 단순히 그러한 목표를 달성하기 위한 수단일 뿐입니다.
AI 자동 생성 콘텐츠
본 콘텐츠는 Lobste.rs ML의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기