Lobsters

Typeclasses vs Modules

타입클래스와 모듈은 서로 다른 문제를 풉니다

타입클래스는 여러 타입에 같은 연산 이름을 쓰는 임의 다형성을, 모듈 시스템은 프로그램을 인터페이스와 구현 단위로 나누는 모듈 추상화를 목표로 합니다. 글은 두 기능의 차이를 예시와 언어별 구현으로 설명하며, 작성자는 대규모 프로그램을 구성할 때 모듈 추상화를 더 중시합니다.

AI 요약

타입클래스(typeclass)와 모듈 시스템(module system)은 인터페이스를 선언하고 구현이 그 인터페이스를 만족하는지 확인한다는 점에서 닮았습니다. 하지만 해결하려는 문제는 다릅니다. 타입클래스는 작은 범위의 코드에서 여러 타입에 같은 식별자와 연산을 쓰도록 하는 임의 다형성(ad-hoc polymorphism)을 지원합니다. 모듈 시스템은 프로그램을 명시적인 구성 요소로 나누고, 각 요소의 내부를 감춘 채 조립하도록 해 큰 프로그램의 구조를 다룹니다.

타입클래스: 여러 타입에 같은 연산을

정수의 덧셈에 쓰는 +를 부동소수점 벡터의 덧셈에도 쓰는 식으로, 타입마다 구현은 달라도 연산 이름을 공유합니다. 타입클래스가 의도하는 바는 기호를 줄이고 추상적인 구조에 익숙한 연산을 재사용하는 것입니다. 예를 들어 +는 타입이 달라도 덧셈이라는 비슷한 의미를 유지해야 합니다. 글은 C++의 <<가 비트 시프트와 스트림 출력처럼 무관한 뜻을 함께 나타내는 사례와 대조합니다.

연산의 의미를 분명히 하려면 법칙도 함께 제시할 수 있습니다. 덧셈의 결합법칙이나 교환법칙을 요구하고 새 구현이 이를 만족하는지 속성 테스트(property test)나 증명으로 확인하는 방식입니다. 다만 Haskell의 Num 타입클래스에는 공식 법칙이 없습니다. 덧셈과 곱셈이 환(ring)을 이룬다는 기대가 관례로 문서화돼 있지만, 부동소수점 곱셈처럼 결합법칙을 만족하지 않는 구현도 Num 인스턴스가 됩니다.

모듈: 프로그램을 구성 요소로 나누기

모듈은 내부 구현을 감추고 외부에 공개할 인터페이스를 정해 프로그램을 부분으로 나눕니다. 입력을 추상화하는 매개변수화 모듈은 특정 인터페이스를 만족하는 모듈을 받아 새 모듈을 만듭니다. 문헌에서는 이런 기능을 펑터(functor)라고 부르기도 합니다. 인터페이스 경계에 테스트나 의미상의 법칙을 붙이면 구성 요소 단위로 검사할 수도 있습니다.

글은 파일 감시 기능을 예로 듭니다. Watcher 매개변수화 모듈은 감시 핸들 타입을 정의하는 모듈을 입력받고, 감시 작업에 필요한 제한된 인터페이스를 내놓습니다. 그 안에는 백엔드 인터페이스 WatchBackend와 이를 받아 실행하는 Run 모듈이 포함됩니다. 또 StringMap은 표준 라이브러리의 Map.S 인터페이스를 만족하면서 키 타입을 문자열로 고정합니다. 이런 구조에서는 인터페이스에 의존하는 코드가 사용자 정의 맵을 쓸 수 있고, 키 타입도 검사됩니다.

타입클래스로 모듈 추상화를 대신할 때

Rust의 FileSystem 트레이트(trait) 사례에서는 필요한 파일 시스템 연산만 인터페이스에 선언해 테스트 경계를 만듭니다. 다만 글은 타입클래스 방식이 모듈보다 표현력이 제한된다고 지적합니다. 모듈은 구성 요소를 재귀적으로 묶고 더 큰 단위의 경계를 만들지만, 타입클래스 인터페이스는 타입에 묶입니다. 새로 정의한 인터페이스를 메서드 반환형 안에 직접 중첩하는 식의 표현도 어렵습니다.

또 해당 인터페이스를 사용하는 함수마다 트레이트 제약을 적어야 해 함수 서명이 길어질 수 있습니다. 모듈 방식은 의존성을 모듈 수준에 둘 수 있습니다. 타입클래스 시스템은 구현을 찾을 때 인스턴스 검색(instance search)을 쓰는 경우가 많습니다. Rust에서는 foo.bar()의 foo 타입을 추론한 뒤 적용 가능한 트레이트 구현을 살펴보므로 컴파일 비용이 늘 수 있다고 설명합니다. 글의 표현을 빌리면 타입클래스는 프로그램을 함수들의 집합으로 바라보게 하고, 모듈은 함수보다 큰 프로그램 단위로 추론하도록 돕습니다.

모듈로 임의 다형성을 구현할 때

반대 방향도 가능합니다. OCaml에서는 이름 공간을 명시하거나 let open Int64처럼 특정 범위에서 모듈을 열어 정수 연산자를 사용하는 방식으로 다형성의 편의를 일부 얻습니다. 전통적인 타입클래스도 시그니처, 타입, 모듈의 전역 데이터베이스를 두고 타입에 맞는 모듈을 찾아 연산을 연결하는 식으로 모듈 관점에서 설명할 수 있습니다. OCaml의 Modular Implicits 제안은 이런 방향을 제시합니다. Scala 3의 Contextual Abstraction도 비슷한 접근이며, 전통적인 타입클래스와 달리 일관성(coherence)을 요구하지 않는다는 차이가 있습니다.

언어별 지원과 글쓴이의 관점

Haskell에는 타입클래스와 별도로 Backpack 모듈 시스템이 있지만 채택이 많지 않습니다. Rocq(구 Coq)도 모듈과 타입클래스 체계를 따로 둡니다. Rust의 모듈 시스템은 캡슐화를 지원하지만 추상 모듈 시그니처나 매개변수화 모듈은 제공하지 않습니다. Elixir는 모듈, behaviours라는 시그니처, protocols라는 타입클래스식 다형성을 갖췄지만, 시그니처를 만족하는 아무 모듈이나 입력으로 받는 기능은 타입 검사를 지원하지 않습니다.

글쓴이는 임의 다형성과 모듈 추상화를 구분해야 한다고 강조합니다. 전자는 작은 범위에서 연산 이름을 재사용하는 기능이고, 후자는 큰 프로그램을 구성하는 기능입니다. 새 언어를 설계할 때 타입클래스를 먼저 도입하고 모듈 추상화를 뒤로 미루는 경우가 많지만, 글쓴이는 고품질 프로그램을 작성할 때 모듈 추상화를 더 가치 있게 여긴다고 밝힙니다.

원문: sm2n.ca / 번역·요약: Trawling