← 목록으로

타입클래스와 모듈 비교

요약
  • 타입클래스는 애드혹 다형성을 통해 동일한 식별자에 공통된 의미를 부여하는 장치이며, 모듈은 프로그램을 분해하고 결합하여 모듈식 추상화를 구현하는 기능입니다.
  • 두 기능은 상호 보완적이지만, 언어 설계 측면에서는 고품질 프로그램 작성을 위해 애드혹 다형성보다 모듈식 추상화 지원을 우선할 것을 권장합니다.
  • 타입클래스는 여러 타입에서 같은 식별자를 사용하는 애드혹 다형성을, 모듈 시스템은 프로그램을 명시적으로 분해하고 조합하는 모듈식 추상화를 주목적으로 함
  • 타입클래스는 + 같은 기호를 여러 타입에 재사용하되 공통된 의미를 유지하려는 장치임. 법칙과 테스트 또는 증명을 함께 두면 구현이 그 의미를 따르는지 확인할 수 있음
  • 모듈은 캡슐화와 매개변수화를 통해 함수보다 큰 단위로 프로그램을 구성하고 추론하게 해 줌. 시그니처에 맞는 모듈을 입력받는 펑터로 의존성을 추상화할 수 있음
  • 타입클래스로 모듈식 추상화를 구현할 수 있지만 인터페이스의 조합성, 함수마다 붙는 제약, 컴파일 시 인스턴스 탐색 비용에서 한계가 있음
  • 두 기능은 양자택일 대상이 아니며 함께 지원할 수 있음. 언어 설계에서는 고품질 프로그램 작성을 위해 애드혹 다형성보다 모듈식 추상화 지원을 우선할 것을 권함
서로 다른 목적과 혼동의 원인
  • 애드혹 다형성(ad-hoc polymorphism) 은 연산자 오버로딩 또는 타입 기반 디스패치라고도 하며, 작은 단위의 코드를 편리하게 작성하도록 같은 식별자를 여러 타입에서 재사용하게 함
    • +를 정수 덧셈과 부동소수점 벡터 덧셈에 함께 사용할 수 있음
    • 벡터 타입은 임의의 라이브러리에서 정의할 수 있으므로, 언어가 모든 동작을 미리 내장할 수는 없음
  • 모듈식 추상화(modular abstraction) 는 큰 프로그램을 효율적으로 구성하기 위한 기능으로, 프로그램을 구성 요소로 재귀적으로 분해하고 결합 관계를 명시하게 함
    • 의존성 주입, 캡슐화, 정보 은닉이 이 기능의 일부에 해당함
  • 두 기능을 혼동하는 데는 역사적 선택과 공통 메커니즘이 영향을 줌
    • 프로그래밍 언어는 역사적으로 둘 중 하나를 택하는 경향이 있었음
    • 둘 다 인터페이스를 명시하고, 코드 묶음이 그 인터페이스에 부합함을 나타내는 수단을 가짐
    • 서로를 어느 정도 흉내 낼 수 있지만, 대체로 최적의 방식은 아님
타입클래스: 같은 기호에 공통된 의미 부여하기
  • 타입클래스의 출발점은 오버로딩한 기호가 타입별로 구현은 달라도 의미상 같은 연산이어야 한다는 것임
    • +는 추상적인 덧셈을 뜻하고, 각 타입이 구체적인 구현을 제공함
    • 반면 C++의 <<는 타입에 따라 비트 시프트나 스트림 리디렉션을 뜻하며, 두 연산은 공통된 의미가 없음
  • +, >>=처럼 추상 구조에 적용되는 소수의 기호를 배우고 여러 구체적인 구조에 재사용하면 기호 수와 인지 부담을 줄이면서 프로그램을 더 쉽게 이해할 수 있음
  • 공통된 의미를 보장하려면 타입클래스에 법칙을 붙이고, 새 구현이 이를 만족하는지 속성 기반 테스트나 증명으로 확인할 수 있음
    • 이상적으로는 각 타입클래스의 의미를 테스트 모음이나 증명 의무로 기계적으로 검사함
    • 부동소수점 연산처럼 일반적인 대수 법칙을 만족하지 않는 경우도 고려해야 함
  • Haskell의 Num은 관례적인 법칙과 실제 인스턴스 사이의 차이를 보여 줌
    • Haskell Report는 Num의 법칙을 정의하지 않지만, 관례적으로 +와 *가 환(ring)을 이룰 것으로 기대함
    • 기대하는 성질은 덧셈의 결합법칙과 교환법칙, 덧셈 항등원과 역원, 곱셈의 결합법칙과 항등원, 덧셈에 대한 곱셈의 분배법칙임
    • Integral도 구현한다면 fromInteger (toInteger i) == i를 기대하며, abs와 signum은 abs x * signum x == x를 만족해야 함
    • 인터페이스는 +, -, *, negate, abs, signum, fromInteger를 제공하며, 정수 리터럴은 fromInteger 적용으로 해석함
    • 부동소수점 곱셈은 결합법칙을 만족하지 않지만, 부동소수점 타입에도 Num 인스턴스가 있음
모듈: 프로그램의 입력과 출력을 추상화하기
  • 모듈은 프로그램을 모듈 또는 구조체라는 부분으로 나누고 결합 관계를 명시하게 함
    • 각 모듈이 내부 일부를 다른 모듈에 숨기면 전체 프로그램을 추론하는 데 필요한 노력을 줄일 수 있음
  • 매개변수화된 모듈은 다른 모듈을 입력받아 모듈을 생성하며, 역사적인 이유로 펑터(functor)라고 부르는 경우가 많음
    • 펑터 자체는 모듈이 아니며, 다른 모듈로 인스턴스화해야 모듈을 생성함
    • 입력으로 허용할 모듈은 시그니처(signature)라는 인터페이스로 제한함
  • 인터페이스에는 관례적인 법칙, 테스트 모음, 증명 의무와 같은 의미적 조건도 붙일 수 있음
    • 모듈식 분해의 흔한 목적 중 하나는 테스트를 쉽게 만드는 것임
    • 중요한 인터페이스를 식별하고 그 경계에서 테스트하면 적은 비용으로 넓은 범위를 검증할 수 있음
OCaml 사례: 파일 감시 기능을 모듈로 구성하기
  • Unison 파일 동기화 프로젝트의 예제는 시그니처 제약과 중첩된 펑터를 함께 사용함
  • StringMap은 표준 라이브러리의 Map.S 시그니처를 따르되, 키 타입을 string으로 고정함
    • 표준 인터페이스에 의존하는 코드는 이 사용자 정의 맵을 사용할 수 있고, 호출 지점의 키 타입도 검사받음
  • Watcher 펑터는 watch 핸들 타입을 정의하는 WatchDescriptor 모듈을 입력받아 플랫폼별 파일 감시 API를 추상화함
    • 결과 모듈은 추상 타입 t와 제한된 파일 디스크립터 유사 API를 제공함
    • ID와 감시 핸들 조회, 감시 핸들 설정, 하위 디렉터리 조회, 루트 여부 확인, ID별 파일 테이블, 디렉터리 경로 계산을 지원함
    • signal_change는 생성/삭제 변경을 알리고, signal_overflow는 오버플로를 알림
  • 내부의 WatchBackend 시그니처는 감시 등록과 해제, 감시 실행, 이벤트 메모리 초기화 API를 정의함
    • 중첩된 Run 펑터는 WatchBackend를 구현한 모듈로 Watcher를 실행함
    • 반환 시그니처가 비어 있으므로 Run은 부수 효과를 위해서만 사용할 수 있음
타입클래스로 모듈식 추상화를 구현할 때의 한계
  • Rust의 FileSystem 트레이트처럼 필요한 연산만 선언하면 해당 인터페이스를 대상으로 테스트하기 쉬워짐
    • 예제는 경로 존재 여부 확인, 파일 타입 조회, 디렉터리 항목 열거, 파일의 문자열 읽기만 요구함
    • 파일 타입 조회는 경로가 없거나 접근할 수 없으면 오류를 반환하며, 파일 타입 조회와 디렉터리 열거는 심볼릭 링크를 따라감
    • 디렉터리 열거는 이름과 파일 타입의 쌍을 결과로 내는 반복자를 반환함
  • 모듈에 비해 분해 방식의 조합성이 부족함
    • read_dir의 반환값을 특정 Item 타입을 가진 Iterator로 지정할 수는 있음
    • 하지만 그 자리에서 새 시그니처를 정의하고 반환값이 이를 만족하도록 요구하는 인라인 인터페이스는 표현할 수 없음
    • 인터페이스는 어떤 타입에 연결돼야 하며 다른 인터페이스 안에 중첩할 수 없음
  • 인터페이스에 의존하는 함수마다 트레이트 제약을 붙여야 하므로 함수 시그니처가 길고 복잡해짐
    • 모듈에서는 의존성을 모듈 수준에 선언해 각 함수의 반복을 피할 수 있음
    • Rust의 impl 블록이 일부 비슷한 역할을 하지만, 이 역시 조합 가능하지 않음
  • 애드혹 다형성을 위한 인스턴스 탐색은 컴파일 비용을 높일 수 있음
    • Rust의 foo.bar()는 먼저 foo의 타입을 추론하고, 관련된 구현 후보와 가능한 bar 구현을 찾아야 함
  • 타입클래스 중심 설계는 프로그램을 함수의 집합으로 바라보게 하는 경향이 있음
    • 전역 타입 데이터베이스가 있고, 각 함수가 스코프에 따라 그 일부를 보는 구조에서는 함수별로 추론해야 함
    • 모듈은 경계를 함수에 한정하지 않으므로, 프로그램의 더 큰 부분을 하나의 단위로 추론할 수 있음
모듈로 애드혹 다형성을 구현하는 방법
  • 어휘적 스코프의 가져오기를 신중하게 사용하면 애드혹 다형성이 없어도 코드가 조금 더 장황해지는 데 그칠 수 있음
    • 명시적인 코드는 기계적으로 이해하기 더 쉬울 수 있고, 컴파일러가 다형적 연산을 해결하기 위한 탐색을 하지 않아 컴파일도 빨라짐
    • 이런 선호가 일부 Zig 사용자에게 Rust보다 Zig가 매력적인 이유일 수 있음
  • OCaml에서는 실제 애드혹 다형성 대신 지역적인 모듈 열기로 연산자마다 네임스페이스를 붙이는 수고를 줄일 수 있음
    • Real World OCaml의 예제는 let open Int64 in 또는 Int64.((x + y) / of_int 2)로 Int64 연산을 사용함
  • 실제 타입 기반 디스패치가 필요하다면 모듈 위에 전역 데이터베이스와 탐색을 구성할 수 있음
    • 시그니처의 전역 데이터베이스를 둠
    • 타입의 전역 데이터베이스를 둠
    • 각 모듈을 타입 하나와 연결한 전역 모듈 데이터베이스를 두되, 한 타입에 여러 모듈을 연결할 수 있음
    • 각 시그니처가 자신을 만족하는 모든 모듈과 그에 연결된 타입을 알도록 함
    • 시그니처의 연산을 사용할 때 타입을 추론하고 해당하는 구체적 모듈을 찾음
  • 이론적으로는 이런 방식으로 타입클래스 중심 접근과 거의 같은 애드혹 다형성을 사용 편의성 손실 없이 구현할 수 있음
    • OCaml의 Modular Implicits 제안이 기본적으로 이 방식을 따름
    • Scala 3의 Contextual Abstraction도 본질적으로 유사한 접근을 지원함
    • Scala 3는 전통적인 타입클래스 시스템과 달리 일관성(coherence)을 요구하지 않으며, 그 세부 사항은 여기서 다루지 않음
모듈과 타입클래스를 함께 지원하는 언어들
  • Haskell은 널리 쓰이는 타입클래스 외에 Backpack 모듈 시스템도 갖추고 있어, 이론적으로 두 추상화를 모두 잘 지원함
    • 다만 Backpack은 생태계에 비교적 늦게 등장했고 채택이 저조함
  • Rocq(이전 이름 Coq)도 독립적인 모듈 시스템과 타입클래스 시스템을 갖춘 것으로 보임
  • Rust는 트레이트와 별도의 모듈 시스템을 갖추지만 완전한 모듈식 프로그래밍을 지원하기에는 기능이 부족함
    • 캡슐화는 지원하지만 추상 모듈 시그니처를 지정할 수 없어 매개변수화된 모듈도 만들 수 없음
  • Elixir는 behaviours와 protocols 로 두 종류의 기능을 제공함
    • 모듈 시그니처를 behaviours라고 부르며, protocols를 통해 타입클래스 방식의 애드혹 다형성을 지원함
    • 점진적 타입 지정으로 모듈이 behaviours를 따르는지 검사할 수 있음
    • 다만 특정 시그니처에 부합하는 임의의 모듈을 입력으로 받겠다고 선언할 수는 없어, 매개변수화된 모듈은 있어도 타입 검사를 받지 않음
언어 설계에서 무엇을 우선할 것인가
  • 모듈식 추상화와 애드혹 다형성은 서로 다른 관심사를 해결하며, 둘 다 유용할 수 있음
    • 전자는 큰 단위의 프로그램 구성을, 후자는 작은 단위의 코드 작성을 돕고, 모듈과 타입클래스는 각각 그 목적을 위한 수단임
  • 새 언어를 설계할 때 타입클래스와 애드혹 다형성을 먼저 챙기고 모듈식 추상화를 뒤로 미루는 우선순위는 바꾸는 편이 좋음
    • 고품질 프로그램을 작성하는 실무에서는 좋은 모듈식 추상화 지원을 애드혹 다형성보다 더 가치 있게 평가함
그냥 목록으로
원문 보기 ↗