Haskell의 타입클래스와 Java의 인터페이스를 대비하며 겉보기 유사점과 중요한 차이를 짚고, 언어 간 대응표보다 각 언어의 사고방식으로 이해하는 접근을 권한다.
제2외국어 학습자는 보통 말하고 싶은 것을 모국어에서 목표 언어로 번역해 말하기 시작한다. 그 과정은 느리고 실수가 잦다. 가능하다면 목표 언어로 “생각하는” 것이 낫다. 다른 프로그래밍 언어에 빗대어 Haskell을 이해하려 할 때도 마찬가지다.
프로그래머들은 대개 “제2언어로서의 Haskell”을 배우며 과거 경험에서 이해하는 개념들과 연결하려 한다. Java 프로그래머는 Haskell의 _타입클래스(typeclass)_가 Java의 _인터페이스(interface)_와 같은지 묻는다. 이는 초기 직관을 얻기엔 괜찮지만, 중요한 차이점을 가린다. 역량이 되어 갈수록 “Haskell로 생각하는 법”을 배우는 편이 더 좋다.
이 질문에 대한 대부분의 답변은 독자가 Java는 알고 Haskell은 모른다고 가정한다. Haskell 원어민은 드물지만, 여기 필자 중 한 명은 Haskell을 첫 프로그래밍 언어로 배웠다. 그래서 이 글은 반대로 가정해 보려 한다. 그렇게 함으로써 Java가 Haskell 원어민에게도 Haskell이 Java 원어민에게 어려운 만큼이나 어렵다는 것을 보여 주고자 한다.
먼저 용어를 분명히 하기 위해 Haskell을 간략히 복습하자.
Haskell의 데이터 구조는 알제브라적 데이터 타입(특히 합과 곱)이며, data 키워드로 정의한다.1 _타입클래스 선언_은 연산의 타입 시그니처를 제시하고, 특정 타입에 대한 구현을 정의하는 _타입클래스 인스턴스_가 여러 방식으로 이를 구현한다. 우리는 어떤 타입클래스 X의 인스턴스를 가진 임의의 타입에 대해 동작하는 함수를 작성할 수 있고, 그 함수는 X가 정의하는 연산들을 사용할 수 있다. 그런 함수가 어떤 구체 타입과 함께 사용될 때, 그 타입이 어떤 구현이 쓰일지를 결정한다. 이것이 우리가 타입을 넘어 추상화하는 방식이다.
다음은 타입클래스의 예다:
class Eq a where
(==), (/=) :: a -> a -> Bool
x /= y = not (x == y)
_클래스 메서드_는 타입클래스가 선언하는 연산이다. Eq 타입클래스에는 (==)와 (/=) 두 메서드가 있다.
클래스의 다른 메서드로부터 유도 가능한 연산의 경우, 최소한의 구현만 작성하고 _기본 메서드_에 나머지 연산을 맡길 수 있다. 위 예시에는 (/=) 하나의 기본 메서드가 있다.
_타입클래스 인스턴스_는 타입클래스 선언에서 묘사된 연산을 특정 타입에 대해 구현하는 곳이다. 인스턴스는 타입과 타입클래스 사이의 유일한 관계다.
아래는 (==)를 정의하고 (/=)는 기본 구현을 사용하는, Bool(타입)에 대한 Eq 타입클래스 인스턴스다:
instance Eq Bool where
True == True = True
False == False = True
_ == _ = False
_타입_은 자료구조와 그에 연관된 타입클래스 인스턴스들의 조합으로 이뤄진다.
Haskell 원어민에게 인터페이스를 설명하기 시작할 때, Haskell에 가깝지 않은 구성요소들은 생략하고, 단순화한 Java의 유사점을 강조할 수 있다.
우리의 단순화된 Java에서 _타입_은 _인터페이스_이거나 클래스(구체적으로는 뒤에서 논할 final 클래스)다. _인터페이스_는 연산의 타입 시그니처를 제시하고, 각 _클래스_가 이를 서로 다른 방식으로 구현하며, 클래스는 특정 하위타입에 대한 구현을 정의한다. 우리는 인터페이스 X에 속하는 값을 다루는 코드를 작성하고 X가 정의하는 연산을 사용할 수 있다. 그 값이 속한 클래스가 어떤 구현이 쓰일지를 결정한다. 이것이 타입을 넘어 추상화하는 방식 — 마치 타입클래스처럼!
하지만 이렇게 설명하면 Haskell과 다른 점을 여럿 가리게 된다.
_메서드_는 Haskell의 최상위 함수 정의와 비슷하지만, 타입의 일부로 정의되며 그 타입의 암묵적 첫 번째 인자를 가진다.
다음은 인터페이스 예시다:
interface Comparator<A> {
Ordering compare(A x, A y);
boolean lessThanOrEqual(A x, A y) {
Ordering o = compare(x, y);
return o == LT || o == EQ;
}
}
이 Comparator 인터페이스에는 compare와 lessThanOrEqual 두 메서드가 있다. compare는 구현 없는 타입 시그니처에 불과하다. lessThanOrEqual은 구현을 제공하므로, Haskell 타입클래스의 용어와 유사하게 _기본 메서드_라고 부른다.
다음은 그 인터페이스를 구현하는 클래스 예시다:
class IntComparator implements Comparator<Integer> {
boolean reverse;
Ordering compare(A x, A y) {
if (x.equals(y))
return EQ;
else if ((x < y) ^ reverse)
return LT;
else
return GT;
}
}
_클래스_는 _필드_를 가지며 데이터 구조를 정의한다(구체적으로, 구조는 필드들의 곱이며, Java에는 합 타입을 직접 표현하는 방법이 없다). 위 IntComparator 클래스에는 reverse라는 필드 하나가 있다. 이 클래스는 또한 Comparator의 compare 메서드를 구현한다.
클래스와 인터페이스 사이의 관계는 _서브타이핑(subtyping)_이다. 클래스 정의는 임의 개수의 인터페이스를 _구현(implements)_한다고 명시할 수 있고, 인터페이스의 메서드를 _상속(inherit)_한다. (전문 용어로, 그 클래스는 각 인터페이스의 _하위타입(subtype)_이고, 인터페이스들은 그 클래스의 _상위타입(supertype)_이라고 한다.)
Haskell과 Java 모두, 오버로드된 이름과 타입 시그니처로 이뤄진 _메서드_라는 개념을 가진다. 차이는 두 언어에서 _메서드_가 정확히 어떻게 정의되는지, 그리고 타입들이 그 위에 놓인 추상화와 어떤 방식으로 연관되는지에서 비롯된다. 이 차이점들은 뒤에서 더 확장해 다룬다.
Java의 타입 시스템 복잡성을 제대로 다루려면 다른 종류의 클래스들도 언급해야 한다. 서브타이핑은 필드를 가지면서 동시에 하위타입을 허용하는 타입들을 도입하면(아래 도해에서 주황색으로 강조) 복잡해진다. 이들은 데이터 구조이자 타입 위의 추상화이기도 하다.
_추상 클래스(abstract class)_의 필드는 데이터 구조의 “일부”를 구성하며, 하위타입에서 추가 필드로 보강될 수 있다.
최종(final) 클래스는 하위타입을 가질 수 없다. final이 아닌 타입은 인터페이스와 마찬가지 방식으로 그 하위타입들에 대한 추상화를 제공한다.
추상이 아닌 클래스는 _인스턴스화 가능(instantiable)_하다. 즉, 생성자를 호출해 그 클래스의 인스턴스를 만들 수 있다.
Java에서 타입은 (그 밖의 역할들 중에서도) 다른 타입을 정의하기 위한 템플릿 역할을 한다. 타입은 실제로 사용하기 위해 필요한 모든 것을 정의하지 않은 “미완성” 상태일 수 있다. 이러한 타입은 오직 그로부터 하위타입을 만들기 위해서만 존재한다. 그 하위타입들이 말하자면 빈칸을 채운다. 정의에 빈틈이 하나도 없을 때 — 더는 정의되지 않은 조각이 없을 때 — 비로소 그 타입은 인스턴스화 가능해진다.
Haskell 원어민에게 인터페이스를 설명할 때 위의 것들을 언급하지 않았던 이유는 Haskell에 그에 상응하는 개념이 마땅치 않기 때문이다. 그러나 Java를 Java의 관점에서 이해하기 위해서는 중요하다.
삼만 피트 상공에서 내려다보면 두 언어 기능은 닮았다. 둘 다 타입들 간의 유사점을 공고화하는 추상화를 정의하고, 이 추상화는 여러 타입에 걸쳐 동작하는 제네릭 코드를 가능하게 한다.
두 언어 사이의 번역은 가능할 때도 있지만, 항상 그런 것은 아니다.
다음 타입클래스 Semigroup을 보자:
class Semigroup a where
(<>) :: a -> a -> a
그리고 Semigroup 제약을 사용하는 함수 triple:
triple :: Semigroup a => a -> a
triple x = x <> x <> x
이 코드를 인터페이스를 사용하는 Java로 옮기는 두 가지 방법을 생각해 볼 수 있다.
첫 번째는 직접 번역이다:
interface Semigroup<A> {
A append(A x, A y);
}
이 Semigroup 인터페이스의 인스턴스들이 곧 세미그룹들이다. Semigroup 인스턴스와 그 타입 매개변수 A 사이의 관계는 유일하지 않다. 어떤 타입에 대해서든 여러 세미그룹을 만드는 것을 막는 장치가 없다. 그러므로 이 인터페이스를 사용할 때는, 어떤 세미그룹을 사용할 것인지 명시하기 위해 Semigroup 인자를 명확히 전달해야 한다.
<A> A triple(A x, Semigroup<A> semigroup) {
return semigroup.append(semigroup.append(x, x), x);
}
타입과 세미그룹 사이에 유일한 연관을 만들고, 사용하는 곳마다 세미그룹 인스턴스를 직접 전달하지 않으려면 작은 리팩터를 할 수 있다. x 매개변수의 타입이 A이므로, 이를 제거하고 세미그룹을 가진 타입들이 인터페이스를 스스로 구현하게 만들어, 명시적 x 매개변수를 Semigroupal<A>를 구현하는 A 클래스의 인스턴스를 가리키는 암묵적 this 매개변수로 바꾼다.
interface Semigroupal<A> {
A appendTo(A y);
}
<A extends Semigroupal<A>> A triple(A x) {
return x.appendTo(x).appendTo(x);
}
이것이 네이티브 Java 사용자가 추상화를 생각하는 더 자연스러운 방식이다. 그러나 이런 식으로 Java 인터페이스로 변환할 수 있는 Haskell 타입클래스의 집합은 제한적이다. 이 예시에서 Semigroup을 Semigroupal로 바꾸는 변환은 append가 타입 A의 인자를 가진다는 사실에 의존했다. 그렇지 않은 경우에는 어떨까?
또 다른 타입클래스 Monoid를 보자:
class Semigroup a => Monoid a where
mempty :: a
앞서처럼 이를 Java로 직접 번역할 수 있다:
interface Monoid<A> extends Semigroup<A> {
A mempty();
}
하지만 더 관용적인 Java 스타일의 변형은 아예 작성할 수 없다.
interface Monoidal<A> extends Semigroupal<A> {
???
}
Java가 할 수 없는 이 일은 _반환 타입 다형성(return type polymorphism)_이라 부른다. mempty의 구현은 함수에 들어오는 인자의 타입이 아니라, 함수에서 나오기를 원하는 타입에 의해 암시된다.
우리는 Java를 비판하려는 것이 아니라, Haskell이 더 배우기 어렵다는 통념에 이의를 제기하려는 것이다. Haskell이 당신의 첫 언어라면, Java가 어렵고, Java가 낯설다. Java도 학습 곡선이 가파르다. Java에 익숙한 Haskell 학습자들이 종종 잊는 부분이다.
두 언어 사이를 오가는 어려움의 일부는 서로 다른 의미를 가진 공용 용어의 덤불 때문이다: 생성자(constructor), 메서드(method), 타입(type), 구체 타입(concrete type), 클래스(class), 인스턴스(instance), 필드(field), 다형(polymorphic). 하지만 일부는 더 깊은 패러다임 충돌에서 온다. Haskell은 전적으로 컴파일 타임에 타입이 해석되고, Java는 서브타이핑과 동적 디스패치를 가진다.
하나를 배우면서 다른 하나에 대한 이해에 매달리고 싶은 유혹이 크다. 하지만 타입 위의 추상화를 제공하는 타입클래스와 인터페이스는 서로 이질적이며, 이를 화해시키기는 어렵다. 알고 있는 것을 과감히 끊고, 배우는 언어를 전적으로 받아들이며 시작하는 편이 더 낫다.
강조
1 보통은 data다. 타입을 도입하는 다른 키워드도 있지만, 그것들이 타입이 정의되고 타입클래스와 연관되는 방식에 주는 영향은 미미하다. 존재적 정량화 타입 같은 일부 Haskell 기능은 더 복잡하지만, 이는 핵심 개념이 아니다.