여러가지 관점에서 볼 수 있다. 공감받진 못하더라도, OOP의 인터페이스가 타입 클래스를 이해하는데 좋은 출발점이라고 생각한다 (당연히 아무것도 모르는 것보단 나을 것이다). 사람들은 개념적으로 타입 클래스가 타입을 집합처럼 구분한다고 말한다. "특정 연산과, 언어 자체로 표현할 수 없는 기대들을 만족하는 타입들의 집합"처럼 말이다. 이 말은 타당하고, "이 타입이 요구 조건을 만족하면 이 타입을 이 클래스의 인스턴스로 만들라"는 아무 메소드 없는 타입 클래스로 표현할 수 있다. 이건 인터페이스에서는 거의 볼 수 없는 활용이다.
구체적인 차이점을 말하자면, OOP 인터페이스보다 타입 클래스가 더 강력한 여러가지가 있다:
-
가장 큰 건 타입 클래스는 타입의 인터페이스 구현 선언과 타입 선언을 분리했다는 점이다. OOP 인터페이스는 타입을 정의할 때 지원하는 인터페이스 목록을 한번 나열하면 그 후에 지원 인터페이스를 추가할 방법이 없었다. 타입 클래스는 의존받는 모듈에 있는 타입이 타입 클래스를 지원할 수 있지만 그 타입 클래스를 모른다면, 인스턴스 선언을 작성하면 끝이다. 만약 네가 외부 라이브러리에서 가져온 타입과 타입 클래스가 있고 그 둘이 서로를 모른다해도, 그냥 인스턴스 선언만 하면 된다. OOP 인터페이스에선 이 한계를 (어댑터) "디자인 패턴"으로 우회할 순 있지만 보통 막히는 부분이다.
-
다음으로 (물론 주관적으로) 큰 점은 OOP 인터페이스는 인터페이스를 구현한 객체로 호출할 수 있는 메소드 집합이지만 타입 클래스는 클래스의 멤버 타입으로 호출할 수 있는 메소드 집합이란 것이다. 이 차이는 중요하다. 타입 클래스 메소드는 객체가 아닌 타입을 참조해서 정의되기에 타입의 여러 객체를 인자로 받거나(상등이나 비교 연산자처럼), 그 타입의 객체를 결과로 제공하거나(여러 산술 연산들), 타입의 상수를 제공하는데(최소나 최대값) 아무 장애가 없다. OOP 인터페이스는 이걸 못해서 OOP 언어는 한계를 우회하기 위해 (clone 메소드를 가진 GoF 프로토타입) 디자인 패턴을 고안했다.
-
OOP 인터페이스는 타입에 대해서만 정의할 수 있다. 타입 클래스는 "타입 생성자"라 부르는 것에도 정의할 수 있다. C에서 유래한 OOP 언어에서 템플릿과 제네릭으로 정의한 여러 컨테이너 타입을 타입 생성자라고 한다. 가령
List
는T
타입을 받아List<T>
타입을 만들어주는 타입 생성자라고 볼 수 있다. 타입 클래스는 타입 생성자에게도 인터페이스를 선언할 수 있게 해준다. -
함수 인자가 여러 인터페이스를 구현해야 할 때, 타입 클래스는 필요한 모든 클래스를 나열하기 쉽다. 하지만 OOP 인터페이스는 포인터 혹은 레퍼런스 인자 타입이 구현해야 할 인터페이스를 하나만 지정할 수 있다. 만약 그 이상 필요하다면 인자를 받아놓고 필요한 인터페이스로 캐스팅하거나 필요한 모든 인터페이스를 인자로 만들고 전부 같은 객체를 가리키도록 요구하는 수밖에 없다. 필요한 모든 인터페이스를 상속하는 빈 인터페이스를 만드는 건 불가능한데, 기존 타입은 그 인터페이스의 조상들을 구현하지 새 인터페이스를 구현하진 않을 것이기 때문이다. (물론 타입 클래스처럼 사후에 구현을 추가할 수 있으면 문제가 아니겠지만, 그럴 수 없다.)
-
어떤 면에선 위 상황의 반대 경우인데, 두 인자가 특정 인터페이스를 구현하는 동시에 두 인자의 타입이 같도록 요구할 수 있다. OOP 인터페이스는 전자만 가능하다.
-
타입 클래스의 인스턴스 선언이 더 유연하다. OOP 인터페이스에선 "인터페이스 Y를 구현하는 타입 X를 선언한다"고 할 때 X, Y는 고정이다. 타입 클래스에선 "원소 타입들이 이 조건들을 만족하는 모든 리스트 타입은 Y의 멤버다"를 표현할 수 있다. (또 하스켈에선 여러가지 문제가 있긴 하지만 "X와 Y를 구현한 모든 타입은 Z의 멤버이기도 하다"도 가능하다.)1
-
"슈퍼클래스 제약"이라고 부르는 것이 인터페이스 상속보다 더 유연하다. OOP 인터페이스에선 "이 인터페이스를 구현하는 타입은 이 인터페이스들도 구현해야 한다"고만 할 수 있다. 타입 클래스도 대개 비슷하지만 슈퍼클래스 제약은 "
SomeTypeConstructor<ThisType>
이 이것저것 인터페이스를 구현해야 한다"나 "이 함수의 결과 타입이 이것저것 제약을 만족해야 한다"고 표현할 수 있다. -
타입 클래스는 현재 (타입 함수처럼) 하스켈의 언어 확장이다. 하지만 여러 타입에 관계한 타입 클래스를 선언할 수 있다. 예를 들어 서로 손실없이 변환 가능한 타입 쌍을 표현하는 동치 클래스가 있다. 물론 OOP 인터페이스를 표현이 불가능하다.
-
더 있을 거라 확신한다.
제네릭을 포함한 OOP 언어는 위에서 말한 몇가지 한계를 해결할 수 있다 (4, 5번째와 어쩌면 2번째도).
반면에 OOP 인터페이스는 가능하지만 타입 클래스는 기본적으로 못하는 것도 있다.
-
런타임 동적 디스패칭. OOP 언어에서는 인터페이스를 구현한 객체 포인터를 넘겨주고 런타임에 동적으로 해당 객체 타입의 메소드를 호출하는 게 간단하다. 반면 타입 클래스 제약은 기본적으로 컴파일 시간에 확정된다. 그리고 놀랍게도 인터페이스가 필요한 대부분의 상황을 커버한다. 만약 동적 디스패칭이 필요하면 객체 요구조건만 남기고 객체 타입이 뭔지 잊어버리는 기능인 (현재 하스켈 언어 확장인) existential type을 쓸 수 있다. 그런 점에서 OOP의 레퍼런스나 포인터가 동작하는 방식과 비슷하게 동작하지만 타입 클래스는 이 점에서 손해를 보진 않는다. (두 존재 타입 인자가 같은 타입 클래스를 구현하고 그 타입 클래스 메소드가 구현 타입의 인자 두 개를 요구하면 존재 타입 인자를 메소드에 건넬 수 없다. 왜냐면 두 존재 타입이 같은 타입인지 모르기 때문이다. 하지만 애초에 그런 메소드를 짤 수 없는 OOP 언어와 비교하면 꿀릴 건 없다.)
-
인터페이스 객체의 런타임 캐스팅. OOP 언어에선 런타임에 포인터나 레퍼런스를 받아 그게 인터페이스를 구현하는지 점검하고 구현한다면 그 인터페이스로 '캐스팅'할 수 있다. 타입 클래스는 대응하는 게 없다(어떤 면에선 장점인 게, 'parametricity'라는 속성을 보존하기 때문인데 거기까진 설명하지 않겠다). 물론 개발자가 객체를 원하는 존재 타입으로 캐스팅하는 메소드를 가진 (기존 클래스를 구현하거나) 새 타입 클래스를 추가하지 못할 이유가 없다. (그런 기능을 라이브러리로 일반화할 수도 있지만, 훨씬 복잡하다. 언젠가 그런 라이브러리를 Hackage에 올릴 계획이다!)
(독자가 이런 것들을 할 순 있지만, 대부분의 함수형 선호 개발자들은 OOP를 따라하는 건 안 좋은 방식이라고 생각하고, 레코드를 쓰거나 타입 클래스같은 더 명료한 해법을 쓸 것을 제안한다. 일급 함수가 있다면, 이 선택지는 꿀릴 게 없다.)
동작 면에서, OOP 인터페이스는 객체가 구현한 인터페이스의 함수 포인터 테이블을 가리키는 포인터들을 저장하는 방식으로 구현된다. (C++같은 다중 인스턴스화를 통한 다형성대신 하스켈처럼 박싱을 통한 다형성을 가진 언어의) 타입 클래스는 대개 "사전 전달"을 통해 구현된다. 컴파일러가 타입 클래스를 사용하는 함수 (혹은 상수) 테이블을 숨겨진 인자로 건네주고, 함수는 얼마나 많은 객체를 받든지간에 클래스당 테이블 하나만 받게 된다 (이게 위에서 두번째에 말한 것들을 하게되는 이유다). 존재 타입의 구현은 OOP 언어의 방식과 많이 비슷하다. 타입 클래스 사전 포인터를 객체에 단서로서 첨부하고 그 멤버 타입에 대해선 잊어버리는 것이다.
만약 당신이 C++의 (원랜 C++11에서 제안된) concepts 제안을 읽어봤다면, 이게 기본적으로 C++ 템플릿으로 각색된 하스켈의 타입 클래스다. 필자는 concepts를 가진 C++에 객체 지향과 가상 함수를 절반으로 줄이고, 신택스와 잡스러운 점을 정리하고, 타입 기반 런타임 디스패칭을 위한 존재 타입을 추가하면 좋을 거라고 생각하곤 한다. (추가: 러스트가 기본적으로 이것이고, 다른 좋은 것들도 들어있다.)
1 역자 주: C# 제네릭의 where 문을 말함. 근데 이 where 문 조건이 충족됨에 따라 인터페이스를 추가로 구현하는 게 가능하기도 하다.