계정: 로그인
AA 📝
5. 타입 클래스와 오버로딩

A Gentle Introduction to Haskell, Version 98
이전 다음 위로


Haskell의 타입 시스템에는 Haskell을 다른 프로그래밍 언어와 차별화시키는 특징이 마지막으로 하나 더 있습니다. 지금까지 주욱 이야기해온 polymorphism은 흔히 parametric polymorphism이라고 부르는 종류입니다. Haskell에는 ad hoc polymorphism이라는 또다른 종류가 존재하며, 이는 일반적으로 overloading으로 더 잘 알려져 있습니다. Ad hoc polymorphism에 대한 약간의 예를 들면:

Parametric polymorphism에서는 타입이 아무런 문제가 되지 않는 반면 (예를 들어 fringe는 트리의 말단 노드들로부터 어떤 타입의 원소가 발견되든 전혀 개의치 않습니다), 위와 같은 오버로딩은 각각의 타입에 대해서 다르게 동작합니다. (사실 이 동작이라는건 때때로 정의되어있지 않거나 오류일 수도 있습니다.) Haskell에서는 타입 클래스가 ad hoc polymorphism 혹은 오버로딩을 제어하는 구조적인 방법을 제공해줍니다.

간단하지만 중요한 예인 동치(equality) 문제로 시작해봅시다. 많은 타입들은 그에 대한 동치성이 정의되면 좋을만한 것들이지만, 일부 타입들은 그렇지 않습니다. 예를 들어, 두 리스트의 동치 비교는 자주 하게되는 반면, 함수들이 같은지 비교하는 것은 일반적으로 계산하기 까다롭다고 여깁니다. (지금 여기서 말하는 동치란 ‘평가치 동일성’을 말하는 것이며, 예를 들어 Java의 ==에서 발견되는 ‘포인터 동일성’과는 다릅니다. 포인터 동일성은 참조 관계에 대해서 투명하지 않을 수 있으며, 따라서 순수한 함수형 언어에는 어울리지 않습니다.) 논지를 부각시키기 위해, 리스트의 원소인지 아닌지를 검사하는 함수 elem의 아래 정의를 살펴보도록 합니다:

   1 x `elem`  []       =  False
   2 x `elem` (y:ys)    =  x==y || (x `elem` ys)

3.1절에서 논의한 스타일 상의 이유 때문에 elem를 중위 형태로 정의하기로 했습니다. ==||는 각각 동치와 논리합을 나타내는 중위 연산자입니다.

직관적으로 말하자면 elem의 타입은 ‘반드시’ a->[a]->Bool이어야만 합니다. 그러나 이렇게 되면, 아까 분명히 ==이 모든 타입에 대해서 정의되어있는건 아니라고 말했음에도 불구하고, ==의 타입이 a->a->Bool이라는 이야기가 되어버립니다.

게다가, 예전에 밝힌 바와 같이, 만일 ==이 모든 타입에 대해서 정의되어있다고 쳐도, 두 리스트의 동치 비교는 두 정수의 동치 비교와는 전혀 다른 문제가 됩니다. 이런 맥락에서, 이러한 여러가지 역할을 수행하기 위해서는 ==이 오버로드되어 있어야할 필요를 느낍니다.

타입 클래스는 이런 문제들을 모두 간편하게 해결해줍니다. 타입 클래스로 인해서 우리는 어떤 타입들이 어떤 클래스의 인스턴스들이라고 선언할 수 있게되고, 클래스에 관련된 오버로드된 연산들을 정의할 수 있게 됩니다. 예를 들어, 동치 연산자를 포함하는 타입 클래스를 정의해봅시다:

   1 class  Eq a  where
   2     (==)    :: a -> a -> Bool

여기서 Eq는 정의하고 있는 클래스의 이름이고 ==는 클래스 내에 들어있는 단 하나의 연산입니다. 이 선언은 “만일 타입 a에 대해서 (오버로드된) 연산 ==적절한 타입으로 정의되어 있다면 타입 a는 클래스 Eq의 인스턴스다”라고 읽을 수 있습니다. (==는 같은 타입의 객체 쌍에 대해서만 정의되어 있음을 주목하시기 바랍니다.)

타입 a가 클래스 Eq의 인스턴스여야만 한다는 제한은 Eq a라고 표현합니다. 그러므로 Eq a는 타입 표현식이라기보다는 오히려 타입에 대한 제한점을 표현하는 것이고, context라고 부릅니다. Context는 타입 표현식의 맨 앞에 놓입니다. 예를 들어, 상기 클래스 선언의 효과는 ==에 다음과 같은 타입을 대입하는 것입니다:

이것은 “클래스 Eq의 인스턴스인 모든 타입 a에 대해서, ==의 타입은 a->a->Bool이다”라고 읽어야합니다. 이것이야말로 elem 예제에 나온 ==에 사용되는 타입이고, 사실 context에 의해 유발된 제한점은 elem에 대한 최우선 타입에까지 영향을 미칩니다:

이것은 “클래스 Eq의 인스턴스인 모든 타입 a에 대해서, elem의 타입은 a->[a]->Bool이다”라고 읽습니다. 이게 바로 우리가 원하던 것이죠. — 이것은 elem이 모든 타입에 대해서 정의되어 있는 것이 아니라 단지 그 원소의 동치 비교를 어떻게 해야하는지 알고 있는 타입에 대해서만 정의되어 있음을 의미합니다.

지금까진 그런대로 괜찮았습니다. 그럼 이제, 어떻게 하면 어떤 타입이 클래스 Eq의 인스턴스인지, 그리고 그 각각의 타입들에 대해서 ==의 실제 행동이 어떤지를 명세할 수 있을까요? 이것은 인스턴스 선언을 통해서 이루어집니다. 예를 들어:

   1 instance  Eq Integer  where
   2     x == y    =  x `integerEq` y

==의 정의는 메서드라고 부릅니다. 함수 integerEq는 여기서 정수의 동치를 비교하는 원시 함수지만, 다른 함수 정의에서와 마찬가지로 일반적으로 올바른 표현식이라면 아무 것이나 다 우변에 올 수 있습니다. 전체 선언은 “타입 Integer는 클래스 Eq의 인스턴스이고, 지금 여기에는 == 연산에 해당하는 메서드의 정의가 있다”라고 말하고 있습니다. 이 선언이 주어지면 이제 우리는 ==을 사용해서 고정 정밀도 정수의 동치 비교를 할 수 있습니다. 비슷하게:

   1 instance  Eq Float  where
   2     x == y    =  x `floatEq` y

위 선언으로 인해 ==를 사용해서 부동소수점 수를 비교할 수 있습니다.

전에 정의했던 Tree같은 재귀적 타입도 다룰 수 있습니다:

   1 instance  (Eq a) => Eq (Tree a)  where
   2     Leaf a       == Leaf b          =  a == b
   3     Branch l1 r1 == Branch l2 r2    =  (l1 == l2) && (r1 == r2)
   4     _            == _               =  False

첫 줄에 있는 context인 Eq a를 주목하십시오. — 이것은 두 번째 줄에서 말단 노드에 있는 (a 타입의) 원소들의 동치 비교를 수행하기 때문에 필요합니다. 추가적인 제한 사항이 말하고 있는 것은, a 타입의 원소에 대한 동치 비교를 어떻게 하는지 알고 있는 한 a의 트리를 서로 비교할 수 있다는 점입니다. 만일 인스턴스 선언에서 context를 빠트리면 static type error가 발생합니다.

Haskell Report, 특히 Prelude에는 유용한 타입 클래스의 예가 잔뜩 들어있습니다. 사실, 클래스 Eq에는 앞서 정의했던 것보다는 약간 더 많은 내용이 정의되어 있습니다:

   1 class  Eq a  where
   2     (==), (/=)    :: a -> a -> Bool
   3     x /= y        =  not (x == y)

이것은 두 개의 연산을 갖는 클래스의 예고, 하나는 동치, 하나는 불일치 비교 연산입니다. 또한 위 예는 default method의 사용법도 보여주고 있으며, 이 경우에는 부등 비교 연산 /=에 대한 기본 메서드입니다. 만일 인스턴스 선언에서 특정 연산에 대한 메서드가 생략되면 클래스 선언에 정의된 디폴트 메서드가 (존재한다면) 대신 사용됩니다. 예를 들어, 앞서 정의한 세 가지 Eq의 인스턴스들은 상기 클래스 선언과 완벽하게 잘 맞물려 작동할 것이며, 우리가 원하는 부등 비교의 올바른 정의(동치 연산의 논리적 부정)를 제대로 사용하게 될 것입니다.

Haskell은 클래스 확장(extension)의 개념도 제공합니다. 예를 들어, 클래스 Eq에 있는 모든 연산들을 상속(inherit)하면서 추가적으로 비교 연산들과 최소/최대 함수를 갖는 클래스 Ord를 정의하고싶어질지도 모릅니다:

   1 class  (Eq a) => Ord a  where
   2     (<), (<=), (>=), (>)    :: a -> a -> Bool
   3     max, min                :: a -> a -> a

클래스 선언에 들어있는 context에 주목하십시오. EqOrd수퍼클래스라고 하고 (반대로 OrdEq서브클래스), Ord의 인스턴스인 모든 타입은 Eq의 인스턴스이기도 합니다. (다음 절에서 Prelude에서 가져온 Ord의 완전한 정의를 보여드리겠습니다.)

이런 클래스 포괄 개념의 장점은 context가 짧아진다는 점입니다: 클래스 EqOrd에 있는 연산들을 모두 사용하는 함수를 위한 타입표현식은 (Eq a, Ord a) 대신 (Ord a)라는 context를 사용할 수 있으며, 이는 OrdEq를 ‘imply’하기 때문입니다.1 더 중요한 점은, 서브 클래스에 있는 연산을 위한 메서드는 수퍼클래스에 있는 연산을 위한 메서드가 존재한다고 가정한다는 사실입니다. 예를 들어, Standard Prelude에 있는 Ord 선언은 (<)을 위해서 아래와 같은 디폴트 메서드를 포함하고 있습니다:

   1     x < y    =  x <= y && x /= y

Ord의 사용 예로서, 2.4.1절에서 정의했던 quicksort의 principal typing은 다음과 같습니다:

다시 말해서, quicksort는 순서를 따질 수 있는 타입의 값들로 이루어진 리스트에 대해서만 작동합니다. quicksort의 이런 타이핑은 그 정의 속에 들어있는 비교 연산자 <>= 때문에 발생합니다.

Haskell은 클래스가 둘 이상의 수퍼클래스를 가질 수도 있게끔 함으로써 다중 상속도 지원합니다. 예를 들어 아래 선언은:

   1 class  (Eq a, Show a) => C a  where ...

클래스 EqShow 모두로부터 연산들을 상속받은 클래스 C를 만듭니다.

Haskell은 클래스 메서드를 최상위 수준 선언으로 취급합니다. 이들은 보통의 변수들과 같은 namespace를 공유합니다. 클래스 메서드와 변수 혹은 다른 클래스의 메서드는 같은 이름을 사용할 수 없습니다.

Context는 data 선언문에서도 사용할 수 있습니다. §4.2.1을 보시기 바랍니다.

클래스 메서드는 현재 클래스를 정의하는 타입 변수만 빼고 나머지 모든 타입 변수에 대해서 추가적인 클래스 제한문을 가질 수 있습니다. 예를 들어 아래 클래스에서:

   1 class  C a  where
   2     m    :: Show b => a -> b

메서드 m은 타입 b가 클래스 Show의 인스턴스일 것을 요구합니다. 그러나, 메서드 m은 타입 a에 대해서는 어떠한 추가적인 클래스 제한문도 사용할 수 없습니다. 대신, (타입 a에 대한) 이런 추가적인 클래스 제한문은 클래스 선언문 자체의 context의 일부여야합니다.

지금까지 우리는 ‘first-order’ 타입을 사용해왔습니다. 예를 들어, 지금까지 타입 생성자인 TreeTree Integer (Integer 값들을 갖고 있는 트리) 혹은 Tree a (a타입의 값을 갖고 있는 트리들을 표현함)에서처럼 항상 인수 하나랑 짝지어져 있었습니다. 그러나 Tree는 그 자체가 타입 생성자고, 그러므로 타입 하나를 인수로 받아서 타입 하나를 결과로 내놓습니다. Haskell에는 이런 타입을 갖는 값이 없지만, 이런 ‘higher-order’ 타입들을 클래스 선언문에서 쓸 수 있습니다.

일단, Prelude에서 따온 아래 Functor 클래스를 봅시다:

   1 class  Functor f  where
   2     fmap    :: (a -> b) -> f a -> f b

함수 fmap은 전에 사용했던 함수 map을 일반화하는 것입니다. 타입 변수 ff af b에서 다른 타입에 적용되었다는 사실에 주목하십시오. 이렇게 타입 변수 f는 인수에 적용될 수 있는 Tree같은 타입에 바운드될거라고 예상할 수 있습니다. 타입 Tree를 위한 Functor의 인스턴스는 다음과 같을 것입니다:

   1 instance  Functor Tree  where
   2     fmap f (Leaf x)          =  Leaf   (f x)
   3     fmap f (Branch t1 t2)    =  Branch (fmap f t1) (fmap f t2)

이 인스턴스 선언문은 Tree a가 아니라 TreeFunctor의 인스턴스라고 선언하고 있습니다. 이런 능력은 매우 유용하며, 여기서는 fmap 같은 함수가 임의의 트리, 리스트 혹은 다른 자료형에 대해서 일정하게 작동할 수 있도록 해주는 일반적인 ‘container’ 타입을 기술할 수 있다는 것을 보여줍니다.

타입 적용은 함수 적용과 같은 방법으로 표현합니다. 타입 T a b(T a) b로 파싱됩니다. 터플처럼 특수 구문을 쓰는 타입은 커링(currying)을 지원하는 또다른 형태로 작성할 수도 있습니다. 함수에 대해서는, (->)는 타입 생성자고 타입 f -> g와 타입 (->) f g는 같은 타입입니다. 마찬가지로, 타입 [a]와 타입 [] a도 같은 것입니다. 터플에 대해서는, 타입 생성자가 (자료 생성자도 마찬가지로) (,), (,,) 등등입니다.

아시다시피, 타입 시스템은 표현식 속의 타이핑 오류들을 찾아줍니다. 하지만 잘못된 타입 표현식 때문에 생기는 오류는 어떨까요? (+) 1 2 3이라는 표현식은 (+)가 인수를 두 개만 받기 때문에 타입 오류를 냅니다. 마찬가지로, Tree Int Int라는 타입은 Tree라는 타입이 하나의 인수만 받기 때문에 모종의 오류를 내야합니다. 그러면, Haskell은 어떻게 해서 잘못된 타입 표현식을 찾아낼까요? 이에 대한 대답은 타입의 정확성을 보증해주는 두번째 타입 시스템입니다! 각각의 타입들은 그 타입이 올바르게 사용되었음을 보증해주는 연관 kind를 갖고 있습니다.

타입 표현식들은 서로 다른 kind들로 분류되며, kind는 두 가지 형태 중 하나를 취합니다:

타입 생성자 Tree의 kind는 *→*입니다. 그리고 타입 Tree Int의 kind는 *입니다. 클래스 Functor의 멤버의 kind는 모두 *→*입니다. 다음과 같은 선언은 kinding error를 냅니다:

   1 instance  Functor Integer  where ...

Integer의 kind는 *이기 때문이죠.

Kind는 Haskell 프로그램에 직접 보이지 않습니다. 컴파일러는 타입 검사를 수행하기 전에 아무런 ‘kind 선언’ 없이도 kind를 추론해냅니다. 잘못된 타입지시문으로 인해 kind error가 나는 경우만 제외하고, kind는 Haskell 프로그램의 이면에 숨어있습니다. Kind 충돌이 일어나는 경우에는 컴파일러가 자세한 오류 메세지를 제공할 수 있을만큼 kind는 심플합니다. Kind에 관한 더 자세한 내용은 §4.1.2§4.6을 보시기 바랍니다.

또다른 관점

타입 클래스의 사용 예를 더 보기 전에, Haskell의 타입 클래스를 바라보는 두 가지 다른 관점들에 대해서 짚어볼 필요가 있습니다. 첫번째는 객체 지향 프로그래밍(OOP)과의 유사성입니다. 아래에 나오는 OOP에 관한 일반적인 서술에서 간단히 클래스타입 클래스로, 객체타입으로 치환함으로써 Haskell의 타입 클래스 메카니즘에 대한 유효한 요약이 만들어집니다:

OOP와는 달리, 타입은 객체가 아니고, 특히 객체나 타입의 internal mutable state라는 개념이 없다는게 명백합니다. 일부 OOP 언어보다 나은 장점은 Haskell의 메서드는 완벽하게 type-safe하다는 점입니다: 메서드를 그 메서드가 요청한 클래스 내에 없는 타입의 값에 적용하려는 시도는 실행할 때가 아니라 컴파일할 때에 감지됩니다. 바꿔 말하면, 메서드는 런타임에 ‘참조되는’게 아니라 단지 higher-order 함수로서 전달될 뿐입니다.

Parametric polymorphism과 ad hoc polymorphism 간의 관계를 생각해보면 또다른 관점을 얻을 수 있습니다. 우리는 지금까지 모든 타입에 대해 전체적으로 정량(universally quatifying)하여 타입 군(群)을 정의하는 데에 있어서 parametric polymorphism이 어떻게 유용한지 살펴보았습니다. 하지만 가끔은 이런 전체 정량의 범위가 너무 넓을 경우가 있으며, 동치 비교 연산을 할 수 있는 타입에 대해서만이라든가 하는 식으로, 조금 작은 크기의 타입 집합에 대해서 정량하고싶을 때가 있습니다. 타입 시스템은 바로 이런 일을 조직적으로 수행할 수 있게끔 해줍니다. 사실 parametric polymorphism도 오버로딩의 일종으로 볼 수 있습니다. 즉, 특정 타입 집합(타입 클래스)에 대해서만이 아니라 모든 타입에 대해서 암묵적으로 오버로딩이 발생한다는 차이 뿐이죠.

다른 언어와의 비교

Haskell에 사용하는 클래스는 C++나 Java 같은 다른 객체지향 언어들이 사용하는 것과 비슷합니다. 그러나, 다음과 같은 몇가지 중요한 차이점이 있습니다:


  1. Ord implies Eq.”를 직역하면 “OrdEq를 의미상 연역적으로 내포한다.” 정도가 되겠지만, 여기서 쓰인 ‘imply’의 의미는 논리학에서 말하는 → 의 의미이기도 합니다. ‘PQ’는 영어로 “P implies Q.”라고 읽고 우리말로는 “P이면 Q이다.” 혹은 “P가 증명되면 Q도 증명된다.”라고 읽게 되며, 또한 P에서 Q로의 함수들의 타입이기도 합니다. 따라서 앞으로 이 문서에서 사용하는 imply라는 단어의 의미에 주의하시기 바랍니다. — 역자 주 (1)


이전 다음 위로
A Gentle Introduction to Haskell, Version 98

Copyright © 1999 Paul Hudak, John Peterson and Joseph Fasel

Permission is hereby granted, free of charge, to any person obtaining a copy of “A Gentle Introduction to Haskell” (the Text), to deal in the Text without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Text, and to permit persons to whom the Text is furnished to do so, subject to the following condition: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Text.