챕터 4: 선언과 바인딩
챕터 4: 선언과 바인딩 (Declarations and Bindings)
이 장에서는 Haskell 선언의 구문(syntax)과 비공식 의미론(informal semantics)을 다뤄요.
Haskell 프로그램을 이루는 가장 기본 단위가 바로 이 '선언(declaration)'인데요. 새 타입을 정의하는 data, newtype, type, 타입 클래스와 오버로딩을 담당하는 class, instance, default, 그리고 값을 바인딩하는 함수·패턴 바인딩과 타이프 시그니처, fixity 선언까지 — 챕터 4는 이 선언들 전부의 뼈대를 잡아주는 장입니다.
공식 보고서 문법을 그대로 따라가며, 각 선언이 "어떻게 생겼는지"와 "무엇을 의미하는지"를 함께 설명할게요.
출처: https://www.haskell.org/onlinereport/haskell2010/haskellch4.html
본문
이 장에서는 Haskell 선언의 구문과 비공식 의미론을 설명해요.
구문 범주 topdecls에 속하는 선언은 Haskell 모듈의 최상위에서만 사용할 수 있어요(챕터 5 참고). 반면 decls는 최상위뿐 아니라 중첩된 스코프(즉 let이나 where 구문 안의 스코프)에서도 쓸 수 있죠.
설명을 위해 선언을 세 묶음으로 나눌게요.
- 사용자 정의 데이터 타입(user-defined datatypes):
type,newtype,data선언으로 구성돼요(섹션 4.2). - 타입 클래스와 오버로딩(type classes and overloading):
class,instance,default선언으로 구성돼요(섹션 4.3). - 중첩 선언(nested declarations): 값 바인딩, 타입 시그니처, fixity 선언으로 구성돼요(섹션 4.4).
Haskell에는 정수나 부동소수점 수처럼 "하드와이어(hard-wired)된" 원시 데이터 타입이 몇 가지 있어요. 하지만 대부분의 "내장" 데이터 타입은 평범한 Haskell 코드 — 즉 보통의 type과 data 선언 — 으로 정의돼요. 이런 "내장" 데이터 타입에 대한 자세한 설명은 섹션 6.1에 있어요.
4.1 타입과 클래스 개요
Haskell은 정적 타입 의미론을 제공하기 위해 전통적인 Hindley-Milner 다형 타입 시스템을 사용해요 [4, 6]. 다만 이 타입 시스템은 오버로드된 함수를 도입할 수 있는 구조화된 방법을 제공하는 타입 클래스(type class, 그냥 클래스라고도 불러요) 로 확장됐어요.
클래스 선언(섹션 4.3.1)은 새 타입 클래스와, 그 클래스의 인스턴스가 되는 모든 타입이 반드시 지원해야 하는 오버로드 연산들을 도입해요. 인스턴스 선언(섹션 4.3.2)은 어떤 타입이 어떤 클래스의 인스턴스임을 선언하고, 그 타입에 대해 인스턴스화된 오버로드 연산들 — 이를 클래스 메서드(class methods) 라고 불러요 — 의 정의를 담아요.
예를 들어, 연산 (+)와 negate를 타입 Int와 Float에 대해 오버로드하고 싶다고 해볼게요. 그러면 Num이라는 새 타입 클래스를 도입하면 돼요:
class Num a where -- simplified class declaration for Num
(+) :: a -> a -> a -- (Num is defined in the Prelude)
negate :: a -> a
이 선언은 "타입 a가 클래스 Num의 인스턴스가 되려면, 주어진 타입을 가진 클래스 메서드 (+)와 negate가 그 위에 정의돼 있어야 한다"라고 읽을 수 있어요.
그러면 Int와 Float를 이 클래스의 인스턴스로 선언할 수 있어요:
instance Num Int where -- simplified instance of Num Int
x + y = addInt x y
negate x = negateInt x
instance Num Float where -- simplified instance of Num Float
x + y = addFloat x y
negate x = negateFloat x
여기서 addInt, negateInt, addFloat, negateFloat는 이 경우 원시 함수(primitive function)라고 가정하지만, 일반적으로는 사용자가 정의한 어떤 함수든 될 수 있어요. 위의 첫 번째 선언은 "Int는 (+)와 negate에 대한 위 정의들(즉 클래스 메서드들)이 증명하듯 클래스 Num의 인스턴스다"라고 읽을 수 있어요.
타입 클래스에 대한 더 많은 예시는 Jones [8]나 Wadler와 Blott [13]의 논문에서 찾을 수 있어요. 'type class'라는 용어는 원래 Haskell 1.0 타입 시스템을 지칭하는 말이었고, 'constructor class'는 원래 타입 클래스의 확장을 지칭하는 말이었어요. 이제는 두 용어를 나눠 쓸 이유가 없어요. 이 보고서에서 'type class'는 원래의 Haskell 타입 클래스와 Jones가 도입한 constructor class를 모두 포괄해요.
4.1.1 종류 (Kinds)
타입 표현식이 유효한지 보장하기 위해, 타입 표현식은 서로 다른 종류(kind) 로 분류돼요. 종류는 두 가지 가능한 형태 중 하나를 취해요:
- 기호
∗는 모든 0항(nullary) 타입 생성자의 종류를 나타내요. - 만약 κ₁과 κ₂가 종류라면, κ₁ → κ₂는 종류 κ₁인 타입을 받아 종류 κ₂인 타입을 돌려주는 타입들의 종류예요.
종류 추론은 타입 추론이 값 표현식의 유효성을 검사하는 것과 비슷한 방식으로 타입 표현식의 유효성을 검사해요. 다만 타입과 달리 종류는 완전히 암묵적이라 언어의 눈에 보이는 부분이 아니에요. 종류 추론은 섹션 4.6에서 다뤄요.
4.1.2 타입의 구문
Haskell 타입 표현식의 구문은 위와 같아요. 데이터 값이 데이터 생성자로 만들어지듯, 타입 값은 타입 생성자(type constructor) 로 만들어져요. 데이터 생성자처럼 타입 생성자의 이름은 대문자로 시작해요. 다만 데이터 생성자와 달리, 중위(infix) 타입 생성자는 (아래 (->)를 제외하고는) 허용되지 않아요.
타입 표현식의 주요 형태는 다음과 같아요:
-
타입 변수(Type variables): 소문자로 시작하는 식별자로 쓰여요. 변수의 종류는 그것이 나타나는 문맥에 의해 암묵적으로 결정돼요.
-
타입 생성자(Type constructors): 대부분의 타입 생성자는 대문자로 시작하는 식별자로 쓰여요. 예를 들면:
Char,Int,Integer,Float,Double,Bool은 종류∗를 가진 타입 상수예요.Maybe와IO는 1항(unary) 타입 생성자이고, 종류∗→∗를 가진 타입으로 취급돼요.- 선언
data T ...또는newtype T ...는 타입 생성자T를 타입 어휘에 추가해요.T의 종류는 종류 추론으로 결정돼요.
특정 내장 타입 생성자에는 특별한 구문이 제공돼요:
- 자명한(trivial) 타입은
()로 쓰이고 종류∗를 가져요. 이 타입은 "0항 튜플" 타입을 뜻하며, 정확히 하나의 값 — 이것 역시()로 쓰여요 — 을 가져요(섹션 3.9와 6.1.5 참고). - 함수 타입은
(->)로 쓰이고 종류∗→∗→∗를 가져요. - 리스트 타입은
[]로 쓰이고 종류∗→∗를 가져요. - 튜플 타입은
(,),(,,)등으로 쓰여요. 이들의 종류는 각각∗→∗→∗,∗→∗→∗→∗등이에요.
(->)와[]상수의 사용은 아래에서 더 자세히 설명해요. -
타입 적용(Type application): 만약 t₁이 종류 κ₁ → κ₂인 타입이고 t₂가 종류 κ₁인 타입이라면, t₁ t₂는 종류 κ₂인 타입 표현식이 돼요.
-
괄호로 싼 타입(Parenthesized type): 형태
(t)는 타입 t와 동일해요.
예를 들어, 타입 표현식 IO a는 상수 IO를 변수 a에 적용한 것으로 이해할 수 있어요. IO 타입 생성자는 종류 ∗→∗를 가지므로, 변수 a와 전체 표현식 IO a가 모두 종류 ∗를 가져야 한다는 결론이 나와요. 일반적으로, 사용자 정의 데이터 타입·타입 동의어·클래스에 적절한 종류를 결정하려면 종류 추론 과정(섹션 4.6 참고)이 필요해요.
특정 타입 표현식을 좀 더 전통적인 스타일로 쓸 수 있게 특별한 구문이 제공돼요:
- 함수 타입은 형태
t1 -> t2를 가지며, 이것은 타입(->) t1 t2와 동등해요. 함수 화살표는 오른쪽으로 결합해요. 예를 들어,Int -> Int -> Float는Int -> (Int -> Float)를 뜻해요. - 튜플 타입은 형태
(t1, …, tk)(k ≥ 2)를 가지며, 이것은 괄호 사이에 콤마가 k−1개 있는 타입(,…,) t1 … tk와 동등해요. 이 타입은 첫 번째 성분이 t₁, 두 번째 성분이 t₂ 등인 k-튜플의 타입을 뜻해요(섹션 3.8과 6.1.4 참고). - 리스트 타입은 형태
[t]를 가지며, 이것은 타입[] t와 동등해요. 이 타입은 원소가 타입 t인 리스트의 타입을 뜻해요(섹션 3.7과 6.1.3 참고).
이 특별한 구문 형태는 스코프에 무엇이 있든 항상 함수·튜플·리스트용 내장 타입 생성자를 가리켜요. 마찬가지로 전위(prefix) 타입 생성자 (->), [], (), (,) 등은 항상 내장 타입 생성자를 가리켜요. 이들은 한정(qualify)될 수도, import/export 목록(챕터 5)에서 언급될 수도 없어요. (그래서 위에 특별한 생성 규칙 "gtycon"이 있는 거예요.)
리스트 타입과 튜플 타입은 특별한 구문이 있지만, 그 의미론은 이에 상응하는 사용자 정의 대수 데이터 타입과 동일해요.
표현식과 타입은 일관된 구문을 가진다는 점을 눈여겨보세요. 만약 tᵢ가 표현식 또는 패턴 eᵢ의 타입이라면, 표현식 (\ e1 -> e2), [e1], (e1,e2)는 각각 타입 (t1 -> t2), [t1], (t1,t2)를 가져요.
한 가지 예외(클래스 선언의 구별되는 타입 변수, 섹션 4.3.1)를 제외하면, Haskell 타입 표현식의 타입 변수는 모두 전역 양화(universally quantified) 되는 것으로 간주돼요. 전역 양화에 대한 명시적 구문은 없어요 [4]. 예를 들어, 타입 표현식 a -> a는 타입 ∀ a. a → a를 뜻해요. 다만 명확성을 위해 Haskell 프로그램의 타입을 논할 때 양화를 명시적으로 쓰는 경우가 종종 있어요. 명시적으로 양화된 타입을 쓸 때, ∀의 스코프는 가능한 한 오른쪽으로 확장돼요. 예를 들어, ∀ a. a → a는 ∀ a. (a → a)를 뜻해요.
4.1.3 클래스 주장(context)과 컨텍스트의 구문
클래스 주장(class assertion)은 형태 qtycls tyvar를 가지며, 타입 tyvar가 클래스 qtycls에 속함을 나타내요. 클래스 식별자는 대문자로 시작해요. 컨텍스트(context)는 0개 이상의 클래스 주장으로 구성되며, 다음과 같은 일반적인 형태를 가져요:
( C1 u1, …, Cn un )
여기서 C₁, …, Cₙ은 클래스 식별자이고, u₁, …, uₙ 각각은 타입 변수이거나 타입 변수에 하나 이상의 타입을 적용한 것이에요. n = 1일 때 바깥 괄호는 생략할 수 있어요. 일반적으로 컨텍스트를 나타낼 때는 cx를 쓰고, 컨텍스트 cx로 제약된 타입 t를 나타낼 때는 cx => t라고 써요. 컨텍스트 cx는 t에서 참조되는 타입 변수만 포함해야 해요. 편의상 컨텍스트 cx가 비어 있어도 cx => t라고 쓰지만, 이 경우 실제 구문에는 =>가 없어요.
4.1.4 타입과 클래스의 의미론
이 절에서는 타입 시스템에 대한 비공식적인 세부 사항을 제공해요. (Wadler와 Blott [13]과 Jones [8]가 각각 타입 클래스와 constructor class를 더 자세히 다뤄요.)
Haskell 타입 시스템은 프로그램의 각 표현식에 타입을 부여해요. 일반적으로 타입은 형태 ∀ u. cx ⇒ t이며, 여기서 u는 타입 변수의 집합 u₁, …, uₙ이에요. 이와 같은 어떤 타입에서든, cx에 자유(free)로 나타나는 전역 양화된 타입 변수 uᵢ는 t에도 자유로 나타나야 해요. 게다가 컨텍스트 cx는 위 섹션 4.1.3에서 설명한 형태여야 해요. 몇 가지 유효한 타입의 예를 들어볼게요:
Eq a => a -> a
(Eq a, Show a, Eq b) => [a] -> [b] -> String
(Eq (f a), Functor f) => (a -> b) -> f a -> f b -> Bool
세 번째 타입에서 제약 Eq (f a)는 f가 전역 양화되기 때문에 더 이상 단순하게 만들 수 없어요.
어떤 표현식 e의 타입은 두 환경에 의존해요. 하나는 e의 자유 변수에 타입을 부여하는 타입 환경(type environment), 다른 하나는 어떤 타입들이 어떤 클래스들의 인스턴스인지 선언하는 클래스 환경(class environment) 이에요(타입은 인스턴스 선언이나 deriving 절이 있을 때만 클래스의 인스턴스가 돼요).
타입들은 일반화 선순서(generalization preorder, 아래에 명시)로 서로 관련돼요. 주어진 환경에서 특정 표현식에 부여할 수 있는, 일반화 선순서가 유도하는 동치 관계까지 고려한 가장 일반적인 타입을 그 표현식의 주 타입(principal type) 이라고 불러요. Haskell의 확장된 Hindley-Milner 타입 시스템은 모든 표현식의 주 타입을 추론할 수 있어요. 여기에는 오버로드된 클래스 메서드의 올바른 사용도 포함돼요(다만 섹션 4.3.4에서 설명하듯 특정 모호한 오버로딩이 생길 수는 있어요). 따라서 명시적 타이핑(이를 타입 시그니처(type signatures) 라고 불러요)은 보통 선택 사항이에요(섹션 3.16과 4.4.1 참고).
타입 ∀ u. cx₁ ⇒ t₁은 타입 ∀ w. cx₂ ⇒ t₂보다 더 일반적(more general) 이라고 불러요. 단, 정의역이 u인 치환 S가 존재해서 다음이 성립할 때예요:
- t₂가 S(t₁)과 동일하다.
- 클래스 환경에서 cx₂가 성립할 때마다, S(cx₁)도 성립한다.
타입 ∀ u. cx ⇒ t의 값은 오직 컨텍스트 cx[s∕u]가 성립할 때에만 타입 s로 인스턴스화될 수 있어요. 예를 들어, 함수 double을 생각해볼게요:
double x = x + x
double의 가장 일반적인 타입은 ∀ a. Num a ⇒ a → a예요. Num Int가 성립하고 Int가 클래스 Num의 인스턴스이기 때문에, double은 Int 타입의 값에 적용할 수 있어요(a를 Int로 인스턴스화). 하지만 Char는 보통 클래스 Num의 인스턴스가 아니므로 double을 Char 타입의 값에 적용하는 것은 일반적으로 불가능해요. 사용자가 그런 인스턴스를 직접 선언할 수도 있는데, 그 경우 double을 Char에 적용해도 실제로 가능해져요.
4.2 사용자 정의 데이터 타입
이 절에서는 대수 데이터 타입(data 선언), 이름을 바꾼 데이터 타입(newtype 선언), 타입 동의어(type 선언)를 설명해요. 이 선언들은 모듈의 최상위에서만 나타날 수 있어요.
4.2.1 대수 데이터 타입 선언
constr의 우선순위는 표현식의 우선순위와 같아요. 즉, 일반 생성자 적용이 중위 생성자 적용보다 높은 우선순위를 가져요(따라서 a : Foo a는 a : (Foo a)로 파싱돼요).
대수 데이터 타입 선언은 다음과 같은 형태를 가져요:
data cx => T u1 … uk = K1 t11 … t1k1 | | Kn tn1 … tnkn
여기서 cx는 컨텍스트예요. 이 선언은 0개 이상의 구성 생성자 K₁, …, Kₙ을 가진 새 타입 생성자 T를 도입해요. 이 보고서에서, 한정하지 않은 용어 "생성자(constructor)"는 항상 "데이터 생성자"를 뜻해요.
데이터 생성자들의 타입은 다음과 같이 주어져요:
Ki :: ∀ u1 … uk. cxi ⇒ ti1 → → tiki → (T u1 … uk)
여기서 cxᵢ는 타입 tᵢ₁, …, tᵢₖᵢ에서 자유로 나타나는 타입 변수만 제약하는, cx의 가장 큰 부분집합이에요. 타입 변수 u₁부터 uₖ까지는 서로 구별되어야 하고 cx와 tᵢⱼ에 나타날 수 있어요. 그 밖의 어떤 다른 타입 변수가 cx나 오른쪽에 나타나면 정적 오류(static error)예요. 새 타입 상수 T는 형태 κ₁ →… → κₖ →∗의 종류를 가져요. 여기서 인자 변수 uᵢ의 종류 κᵢ는 섹션 4.6에서 설명하는 종류 추론으로 결정돼요. 이는 T가 0개에서 k개 사이의 인자를 가진 타입 표현식에 사용될 수 있음을 뜻해요.
예를 들어, 다음 선언은
data Eq a => Set a = NilSet | ConsSet a (Set a)
종류 ∗→∗의 타입 생성자 Set, 그리고 다음과 같은 타입을 가진 생성자 NilSet과 ConsSet을 도입해요.
NilSet :: ∀ a. Set a
ConsSet :: ∀ a. Eq a ⇒ a → Set a → Set a
이 예시에서 ConsSet의 오버로드된 타입은 ConsSet이 클래스 Eq의 인스턴스인 타입의 값에만 적용될 수 있음을 보장해요. ConsSet에 대한 패턴 매칭도 Eq a 제약을 만들어내요. 예를 들면:
f (ConsSet a s) = a
함수 f의 추론된 타입은 Eq a => Set a -> a예요. data 선언의 컨텍스트는 그 외에는 아무런 효과가 없어요.
데이터 타입의 생성자에 대한 가시성(즉 데이터 타입의 "추상성")은 그 데이터 타입이 정의된 모듈 밖에서, 섹션 5.8에서 설명하듯 데이터 타입 이름이 export 목록에 나타나는 형태에 의해 제어돼요.
data 선언의 선택적인 deriving 부분은 파생 인스턴스(derived instances)와 관련되며 섹션 4.3.3에서 설명해요.
라벨 붙은 필드 (Labelled Fields)
항수(arity)가 k인 데이터 생성자는 k개의 성분을 가진 객체를 만들어요. 이 성분들은 보통 표현식이나 패턴에서 생성자의 인자로 위치 기반으로 접근돼요. 큰 데이터 타입에서는 데이터 객체의 성분에 필드 라벨(field labels) 을 붙이는 것이 유용해요. 그러면 생성자 안에서의 위치와 무관하게 특정 필드를 참조할 수 있게 돼요.
data 선언의 생성자 정의는 레코드 구문 C { ... }을 사용해 생성자 필드에 라벨을 붙일 수 있어요. 필드 라벨을 사용하는 생성자와 그렇지 않은 생성자는 자유롭게 섞을 수 있어요. 필드 라벨이 연관된 생성자여도 보통의 생성자로 계속 사용할 수 있는데, 라벨을 쓰는 기능은 밑바탕의 위치 기반 생성자를 사용하는 연산을 위한 줄임 표기(short hand)일 뿐이에요. 위치 기반 생성자의 인자들은 라벨 붙은 필드와 같은 순서로 나타나요. 예를 들어, 선언
data C = F { f1,f2 :: Int, f3 :: Bool }
은 다음이 만들어내는 것과 동일한 타입과 생성자를 정의해요.
data C = F Int Int Bool
필드 라벨을 사용하는 연산은 섹션 3.15에서 설명해요. data 선언은, 필드의 타이핑이 타입 동의어 확장 후 모든 경우에 같기만 하면, 여러 생성자에서 같은 필드 라벨을 사용할 수 있어요. 라벨은 스코프 안에서 하나 이상의 타입과 공유될 수 없어요. 필드 이름은 최상위 네임스페이스를 평범한 변수·클래스 메서드와 공유하며, 스코프 안의 다른 최상위 이름과 충돌하면 안 돼요.
패턴 F {}는 생성자 F로 만들어진 어떤 값과도 매칭되는데, F가 레코드 구문으로 선언됐는지 여부와 무관해요.
엄격성 플래그 (Strictness Flags)
데이터 생성자가 적용될 때마다, 생성자의 각 인자는 대수 데이터 타입 선언에서 대응하는 타입이 엄격성 플래그(strictness flag) 를 가질 때에만 평가돼요. 엄격성 플래그는 느낌표 "!"로 표시돼요. 어휘상 "!"는 보통의 varsym이지 reservedop가 아니며, data 선언의 인자 타입이라는 문맥에서만 특별한 의미를 가져요.
번역: 다음 형태의 선언
data cx => T u1 … uk = … | K s1 … sn | …
에서 각 sᵢ는 !ti 또는 ti 형태예요. 이 선언은 표현식에서 K가 나타날 때마다 아래 것으로 대체해요.
(\ x1 … xn -> ( ((K op1 x1) op2 x2) … ) opn xn)
여기서 sᵢ가 ti 형태이면 opᵢ는 비엄격 적용 함수 $이고, sᵢ가 ! ti 형태이면 opᵢ는 엄격 적용 함수 $!(섹션 6.2 참고)예요. K에 대한 패턴 매칭은 엄격성 플래그의 영향을 받지 않아요.
4.2.2 타입 동의어 선언
타입 동의어 선언은 기존 타입과 동등한 새 타입을 도입해요. 다음과 같은 형태를 가져요:
type T u1 … uk = t
이 선언은 새 타입 생성자 T를 도입해요. 타입 (T t1 … tk)는 타입 t[t1∕u1, …, tk∕uk]와 동등해요. 타입 변수 u₁부터 uₖ까지는 서로 구별되어야 하고 t에만 스코프되며, 그 외 다른 타입 변수가 t에 나타나면 정적 오류예요. 새 타입 생성자 T의 종류는 형태 κ₁ →… → κₖ → κ이고, 인자 uᵢ의 종류 κᵢ와 오른쪽 t의 종류 κ는 섹션 4.6에서 설명하는 종류 추론으로 결정돼요. 예를 들어, 다음 정의로 리스트 타입 생성자를 쓰는 대안적인 방법을 제공할 수 있어요:
type List = []
타입 동의어 선언으로 도입된 타입 생성자 기호 T는 부분 적용될 수 없어요. T를 인자의 전체 개수 없이 사용하면 정적 오류예요.
재귀적·상호 재귀적 데이터 타입은 허용되지만, 타입 동의어는 그렇지 않아요 — 대수 데이터 타입이 중간에 개입하지 않는 한은요. 예를 들어,
type Rec a = [Circ a]
data Circ a = Tag [Rec a]
는 허용되는데 반해,
type Rec a = [Circ a] -- invalid
type Circ a = [Rec a] -- invalid
는 허용되지 않아요. 마찬가지로 type Rec a = [Rec a]도 허용되지 않아요.
타입 동의어는 타입 시그니처를 더 읽기 쉽게 만드는 편리하지만 순전히 구문적인(syntactic) 메커니즘이에요. 동의어와 그 정의는 — 인스턴스 선언(섹션 4.3.2)의 인스턴스 타입에서를 제외하면 — 완전히 상호 교환 가능해요.
4.2.3 데이터 타입 이름 바꾸기
다음 형태의 선언
newtype cx => T u1 … uk = N t
은 기존 타입과 같은 표현(representation)을 가진 새 타입을 도입해요. 타입 (T u1 … uk)는 데이터 타입 t를 이름만 바꾼(rename) 것이에요. 이것은 원래 타입으로/에서 명시적으로 강제 변환(coerce)해야 하는 구별되는 타입을 만든다는 점에서 타입 동의어와 달라요. 또 타입 동의어와 달리, newtype은 재귀적 타입을 정의하는 데 사용될 수 있어요. 표현식에서 생성자 N은 값의 타입을 t에서 (T u1 … uk)로 강제 변환해요. 패턴에서 N을 사용하면 값의 타입을 (T u1 … uk)에서 t로 강제 변환해요. 이 강제 변환들은 실행 시간 오버헤드 없이 구현될 수 있어요. newtype은 객체의 밑바탕 표현을 바꾸지 않아요.
newtype으로 정의된 타입에는 새 인스턴스(섹션 4.3.2 참고)를 정의할 수 있지만, 타입 동의어에는 정의할 수 없어요. newtype으로 만들어진 타입은 대수 데이터 타입과, 대수 데이터 타입의 표현에는 한 단계 더 간접(indirection)이 있다는 점에서 달라요. 이 차이는 표현에 접근하는 효율성을 떨어뜨릴 수 있어요. 이 차이는 패턴 매칭에 대한 다른 규칙(섹션 3.17 참고)에도 반영돼요. 대수 데이터 타입과 달리, newtype 생성자 N은 unlifted라서 N ⊥은 ⊥와 같아요.
다음 예는 data(대수 데이터 타입), type(타입 동의어), newtype(이름 바꾸는 타입) 사이의 차이를 분명히 해줘요. 다음 선언들이 주어졌다고 할게요:
data D1 = D1 Int
data D2 = D2 !Int
type S = Int
newtype N = N Int
d1 (D1 i) = 42
d2 (D2 i) = 42
s i = 42
n (N i) = 42
표현식 (d1 ⊥), (d2 ⊥), (d2 (D2 ⊥))는 모두 ⊥와 동등해요. 반면 (n ⊥), (n (N ⊥)), (d1 (D1 ⊥)), (s ⊥)는 모두 42와 동등해요. 특히 (N ⊥)은 ⊥와 동등하지만 (D1 ⊥)은 ⊥와 동등하지 않아요.
newtype 선언의 선택적인 deriving 부분은 data 선언의 deriving 성분과 같은 방식으로 취급돼요. 섹션 4.3.3 참고.
newtype 선언은 필드 이름 구문을 사용할 수 있는데, 물론 필드는 하나만 있을 수 있어요. 따라서:
newtype Age = Age { unAge :: Int }
이 선언은 생성자와 파괴자(de-constructor)를 모두 스코프 안으로 가져와요:
Age :: Int -> Age
unAge :: Age -> Int
4.3 타입 클래스와 오버로딩
4.3.1 클래스 선언
클래스 선언은 새로운 클래스와 그 위의 연산(클래스 메서드)들을 도입해요. 클래스 선언은 다음과 같은 일반적인 형태를 가져요:
class cx => C u where cdecls
이 선언은 새 클래스 이름 C를 도입해요. 타입 변수 u는 클래스 본문 안의 클래스 메서드 시그니처에만 스코프돼요. 컨텍스트 cx는 아래에서 설명하듯 C의 수퍼클래스(superclasses) 를 지정해요. cx에서 참조할 수 있는 유일한 타입 변수는 u예요.
수퍼클래스 관계는 순환해서는 안 돼요. 즉, 방향성 비순환 그래프(directed acyclic graph)를 형성해야 해요.
클래스 선언의 cdecls 부분은 세 종류의 선언을 담아요:
-
클래스 선언은 새 클래스 메서드 vᵢ를 도입하는데, 그 스코프는 클래스 선언 밖으로 확장돼요. 클래스 선언의 클래스 메서드는 정확히, cdecls 안에 명시적 타입 시그니처
vi :: cxi => ti를 가진 vᵢ들로 구성돼요. 클래스 메서드는 변수 바인딩·필드 이름과 최상위 네임스페이스를 공유해요. 따라서 최상위 바인딩, 필드 이름, 또는 다른 클래스 메서드와 이름이 같으면 안 돼요. 즉, 클래스 메서드는 최상위 정의, 필드 이름, 또는 다른 클래스 메서드와 같은 이름을 가질 수 없어요.
최상위 클래스 메서드 vᵢ의 타입은 다음과 같아요:
vi :: ∀u,w. (Cu,cxi) ⇒ titᵢ는 u를 언급해야 해요. u가 아닌 다른 타입 변수 w를 언급해도 되는데, 그 경우 vᵢ의 타입은 u와 w 양쪽에 다형적이에요. cxᵢ는 w만 제약할 수 있어요. 특히 cxᵢ는 u를 제약할 수 없어요. 예를 들면:
class Foo a where op :: Num b => a -> b -> a여기서 op의 타입은 ∀ a, b. (Foo a, Num b) ⇒ a → b → a예요.
-
cdecls는 클래스 메서드들 중 어느 것에 대한 fixity 선언도 담을 수 있어요(다만 그 외의 값에 대해서는 안 돼요). 하지만 클래스 메서드는 최상위 값을 선언하므로, 클래스 메서드에 대한 fixity 선언은 대안으로 클래스 선언 밖, 최상위에 나타날 수도 있어요.
-
마지막으로, cdecls는 vᵢ 어느 것에 대한 기본 클래스 메서드(default class method) 도 담을 수 있어요. vᵢ의 기본 클래스 메서드는 특정 인스턴스 선언(섹션 4.3.2 참고)에서 그에 대한 바인딩이 주어지지 않을 때 사용돼요. 기본 메서드 선언은 평범한 값 정의인데, 왼쪽이 변수나 함수 정의만 될 수 있다는 점만 달라요. 예를 들면:
class Foo a where op1, op2 :: a -> a (op1, op2) = ...은 허용되지 않는데, 기본 선언의 왼쪽이 패턴이기 때문이에요.
이 경우를 제외하면 cdecls에는 다른 선언이 허용되지 않아요.
where 부분이 없는 클래스 선언은 클래스들의 모임을, 원래 클래스들의 클래스 메서드를 모두 상속하는 더 큰 하나의 클래스로 결합하는 데 유용할 수 있어요. 예를 들면:
class (Read a, Show a) => Textual a
이런 경우, 타입이 모든 수퍼클래스의 인스턴스여도, 설령 하위 클래스에 직접적인 클래스 메서드가 없더라도, 그 타입이 자동으로 하위 클래스의 인스턴스가 되지는 않아요. 인스턴스 선언은 where 부분 없이 명시적으로 주어져야 해요.
4.3.2 인스턴스 선언
인스턴스 선언은 클래스의 인스턴스를 도입해요.
class cx => C u where { cbody }
를 클래스 선언이라고 할게요. 대응하는 인스턴스 선언의 일반적인 형태는 다음과 같아요:
instance cx′ => C (T u1 … uk) where { d }
여기서 k ≥ 0이에요. 타입 (T u1 … uk)는 타입 생성자 T를 단순 타입 변수 u₁, … uₖ에 적용한 형태여야 해요. 게다가 T는 타입 동의어가 아니어야 하고, uᵢ는 모두 서로 구별되어야 해요.
이것은 다음과 같은 인스턴스 선언을 금지해요:
instance C (a,a) where ...
instance C (Int,a) where ...
instance C [[a]] where ...
선언 d는 C의 클래스 메서드에 대한 바인딩만을 담을 수 있어요. 스코프에 없는 클래스 메서드에 대한 바인딩을 주는 것은 불법이지만, 그 이름이 어떤 이름으로 스코프에 있는지는 중요하지 않아요. 특히 한정된 이름(qualified name)일 수 있어요. (이 규칙은 export 목록의 종속 이름에 사용되는 규칙과 동일해요 — 섹션 5.2.) 예를 들어, 다음은 range가 한정된 이름 Data.Ix.range로만 스코프에 있어도 합법이에요.
module A where
import qualified Data.Ix
instance Data.Ix.Ix T where
range = ...
선언에는 타입 시그니처나 fixity 선언이 들어갈 수 없는데, 이들은 이미 클래스 선언에서 주어졌기 때문이에요. 기본 클래스 메서드(섹션 4.3.1)의 경우와 마찬가지로, 메서드 선언은 변수 또는 함수 정의의 형태를 취해야 해요.
어떤 클래스 메서드에 대한 바인딩이 주어지지 않으면, 클래스 선언의 대응하는 기본 클래스 메서드가 (있다면) 사용돼요. 그런 기본값이 존재하지 않으면 이 인스턴스의 클래스 메서드는 undefined에 바인딩되고 컴파일 시간 오류는 발생하지 않아요.
타입 T를 클래스 C의 인스턴스로 만드는 인스턴스 선언을 C-T 인스턴스 선언이라 부르며, 다음 정적 제약들을 받아요:
-
한 타입은 프로그램 안에서 특정 클래스의 인스턴스로 두 번 이상 선언될 수 없어요.
-
클래스와 타입은 같은 종류를 가져야 해요. 이는 섹션 4.6에서 설명하는 종류 추론으로 결정할 수 있어요.
-
인스턴스 타입
(T u1 … uk)의 타입 변수들이 인스턴스 컨텍스트 cx′의 제약들을 만족한다고 가정해요. 이 가정 아래에서 다음 두 조건도 만족해야 해요:- C의 수퍼클래스 컨텍스트
cx[(T u1 … uk)∕u]가 표현하는 제약들이 만족돼야 해요. 다시 말해, T가 C의 각 수퍼클래스의 인스턴스여야 하고, 모든 수퍼클래스 인스턴스의 컨텍스트들이 cx′에 의해 함의(imply)돼야 해요. - d의 클래스 메서드 선언들이 잘 타이핑되기 위해 인스턴스 타입의 타입 변수들에 필요한 어떤 제약들이든 만족돼야 해요.
사실 병리적인 경우를 제외하면, 위 두 제약을 만족하는 가장 일반적인 인스턴스 컨텍스트 cx′를 인스턴스 선언에서 추론하는 것이 가능해요. 그럼에도 명시적 인스턴스 컨텍스트를 쓰는 것은 의무예요.
- C의 수퍼클래스 컨텍스트
다음 예는 수퍼클래스 인스턴스가 부과하는 제약들을 보여줘요:
class Foo a => Bar a where ...
instance (Eq a, Show a) => Foo [a] where ...
instance Num a => Bar [a] where ...
이 예는 유효한 Haskell이에요. Foo가 Bar의 수퍼클래스이므로, 두 번째 인스턴스 선언은 Num a라는 가정 아래에서 [a]가 Foo의 인스턴스일 때에만 유효해요. 첫 번째 인스턴스 선언이 바로 이 가정 아래에서 [a]가 Foo의 인스턴스라고 말해주는데, Eq와 Show가 Num의 수퍼클래스이기 때문이에요.
두 인스턴스 선언이 대신 다음과 같다면:
instance Num a => Foo [a] where ...
instance (Eq a, Show a) => Bar [a] where ...
프로그램이 무효가 돼요. 두 번째 인스턴스 선언은 (Eq a, Show a)라는 가정 아래에서 [a]가 Foo의 인스턴스일 때에만 유효한데, 그렇지 않기 때문이에요. [a]는 더 강한 가정 Num a 아래에서만 Foo의 인스턴스이기 때문이죠.
인스턴스 선언의 더 많은 예는 챕터 9에서 찾을 수 있어요.
4.3.3 파생 인스턴스
섹션 4.2.1에서 언급했듯, data와 newtype 선언은 선택적인 deriving 형태를 담아요. 이 형태가 포함되면, 명명된 각 클래스에 대해 그 데이터 타입의 파생 인스턴스 선언이 자동으로 생성돼요. 이 인스턴스들은 사용자가 정의한 인스턴스와 같은 제약을 받아요. 타입 T에 대해 클래스 C를 파생할 때, C의 모든 수퍼클래스에 대한 인스턴스가 T에 존재해야 해요. 이는 명시적 인스턴스 선언을 통하거나 수퍼클래스를 deriving 절에 포함시켜서 달성돼요.
파생 인스턴스는 사용자 정의 데이터 타입에 편리하고 흔히 쓰이는 연산들을 제공해요. 예를 들어, 클래스 Eq의 데이터 타입에 대한 파생 인스턴스는 연산 ==와 /=를 정의해서 프로그래머가 그것들을 직접 정의할 필요를 없애줘요.
Prelude에서 파생 인스턴스가 허용되는 유일한 클래스는 Eq, Ord, Enum, Bounded, Show, Read이며, 모두 Figure 6.1에 언급돼 있어요. 파생 인스턴스가 이 각 클래스에 대해 정확히 어떻게 생성되는지에 대한 자세한 내용은 챕터 11에 있어요. 여기에는 그러한 파생 인스턴스가 가능한 때에 대한 명세도 포함돼요. 표준 라이브러리가 정의한 클래스도 파생 가능할 수 있어요.
deriving 형태에 명명된 클래스에 대해 인스턴스 선언을 파생할 수 없으면 정적 오류가 발생해요. 예를 들어, 모든 데이터 타입이 Enum의 클래스 메서드를 제대로 지원할 수는 없어요. 파생된 클래스에 대해 명시적 인스턴스 선언을 주는 것도 정적 오류예요.
data 또는 newtype 선언에서 deriving 형태를 생략하면 그 데이터 타입에 대해 어떤 인스턴스 선언도 파생되지 않아요. 즉, deriving 형태를 생략하는 것은 빈 deriving 형태 deriving ()을 포함하는 것과 동등해요.
4.3.4 모호한 타입, 그리고 오버로드된 수치 연산의 기본값
Haskell식 오버로딩에 본질적으로 내재된 문제는 모호한 타입(ambiguous type) 의 가능성이에요. 예를 들어, 챕터 11에서 정의된 read와 show 함수를 사용하고, Read와 Show의 멤버가 Int와 Bool뿐이라고 가정하면, 표현식
let x = read "..." in show x -- invalid
은 모호해요. show와 read의 타입이
show :: ∀ a. Show a ⇒ a → String
read :: ∀ a. Read a ⇒ String → a
이므로, a를 두 경우 모두 Int로 인스턴스화하거나 Bool로 인스턴스화해서 만족시킬 수 있기 때문이에요. 이런 표현식은 ill-typed, 즉 정적 오류로 간주돼요.
표현식 e의 타입 ∀ u. cx ⇒ t에서, u에 속한 타입 변수 u가 cx에는 나타나지만 t에는 나타나지 않는 경우, 우리는 e가 모호한 타입을 가진다고 말해요. 이런 타입은 무효예요.
예를 들어, 앞서 show와 read를 포함한 표현식은 타입이 ∀ a. Show a, Read a ⇒ String이므로 모호한 타입을 가져요.
모호한 타입은 사용자의 입력으로만 우회할 수 있어요. 한 가지 방법은 섹션 3.16에서 설명하는 표현식 타입 시그니처(expression type-signatures) 를 사용하는 것이에요. 예를 들어, 앞서 준 모호한 표현식에 대해 다음과 같이 쓸 수 있어요:
let x = read "..." in show (x::Bool)
이것은 타입을 명확히(disambiguate) 해줘요.
때로는 표현식 타입 시그니처로 고정 타입을 주기보다, 다른 모호한 표현식을 어떤 변수와 같은 타입으로 만들어야 할 때가 있어요. 이것이 함수 asTypeOf(챕터 9)의 목적이에요. x 'asTypeOf' y는 x의 값을 가지지만, x와 y는 같은 타입으로 강제돼요. 예를 들면:
approxSqrt x = encodeFloat 1 (exponent x 'div' 2) 'asTypeOf' x
(encodeFloat와 exponent에 대한 설명은 섹션 6.4.6 참고.)
Num 클래스에서의 모호성이 가장 흔하므로, Haskell은 그것을 해결할 또 다른 방법을 제공해요 — default 선언이에요:
default (t1 , … , tn)
여기서 n ≥ 0이고, 각 tᵢ는 Num ti가 성립하는 타입이어야 해요. 모호한 타입이 발견된 상황에서, 모호한 타입 변수 v는 다음 조건을 만족하면 기본값화 가능(defaultable) 해요:
- v가
C v형태의 제약에만 나타나는데, C는 클래스이고, - 이 클래스 중 적어도 하나는 수치 클래스(즉
Num또는Num의 하위 클래스)이고, - 이 클래스들이 모두 Prelude나 표준 라이브러리에 정의돼 있다. (Figure 6.2–6.3은 수치 클래스를, Figure 6.1은 Prelude에 정의된 클래스를 보여줘요.)
각 기본값화 가능한 변수는, 모호한 변수의 클래스들 모두의 인스턴스인, 기본값 목록의 첫 번째 타입으로 대체돼요. 그런 타입을 찾지 못하면 정적 오류예요.
모듈당 default 선언은 하나만 허용되고, 그 효과는 해당 모듈로 제한돼요. 모듈에 default 선언이 주어지지 않으면 다음과 같은 것으로 간주돼요:
default (Integer, Double)
빈 default 선언 default ()은 모듈 안의 모든 기본값을 꺼요.
4.4 중첩 선언
다음 선언들은 어떤 선언 목록에서도 사용될 수 있어요. 모듈의 최상위도 포함해서요.
4.4.1 타입 시그니처
타입 시그니처는 변수들의 타입을, 가능하면 컨텍스트와 함께, 지정해요. 타입 시그니처는 다음과 같은 형태를 가져요:
v1, …, vn :: cx => t
이것은 1부터 n까지의 각 i에 대해 vi :: cx => t를 단언하는 것과 동등해요. 각 vᵢ는 타입 시그니처를 포함하는 것과 같은 선언 목록 안에 값 바인딩을 가져야 해요. 즉, 바깥 스코프에 바인딩된 변수에 대해 타입 시그니처를 주는 것은 무효예요. 게다가 한 변수에 대해, 설령 시그니처가 동일해도, 하나보다 많은 타입 시그니처를 주는 것은 무효예요.
섹션 4.1.2에서 언급했듯, 시그니처에 나타나는 모든 타입 변수는 그 시그니처에 대해 전역 양화되며, 따라서 타입 변수의 스코프는 그것을 포함하는 타입 시그니처로 제한돼요. 예를 들어, 다음 선언들에서
f :: a -> a
f x = x :: a -- invalid
두 타입 시그니처의 a들은 서로 전혀 구별돼요. 실제로 이 선언들은 정적 오류를 담고 있는데, x가 타입 ∀ a. a를 가지지 않기 때문이에요. (x의 타입은 f의 타입에 의존해요. 현재 Haskell에는 의존 타입을 가진 변수의 시그니처를 지정할 방법이 없어요. 이는 섹션 4.5.4에서 설명해요.)
주어진 프로그램이 변수 f에 대한 시그니처를 포함하면, f의 각 사용은 선언된 타입을 가진 것으로 취급돼요. f의 정의하는 발생(defining occurrence)에 대해 같은 타입을 추론할 수 없다면 정적 오류예요.
변수 f가 대응하는 타입 시그니처 선언 없이 정의되면, f가 자신의 선언 그룹(declaration group, 섹션 4.5 참고) 밖에서 사용될 때마다 대응하는 추론된 타입, 즉 주 타입(principal type) 을 가진 것으로 취급돼요. 다만 타입 추론이 여전히 가능하도록, 정의하는 발생과 f가 자신의 선언 그룹 안에서 사용되는 모든 곳이 같은 단형(monomorphic) 타입을 가져야 해요. 여기서 주 타입은 섹션 4.5.2에서 설명하는 일반화로 얻어져요.
예를 들어, 만약 우리가
sqr x = x⋆x
를 정의하면, 주 타입은 sqr :: ∀ a. Num a ⇒ a → a이므로 sqr 5나 sqr 0.1 같은 적용이 허용돼요. sqr :: Int -> Int처럼 더 구체적인 타입을 선언하는 것도 유효하지만, 이제 sqr 0.1 같은 적용은 무효가 돼요. 다음과 같은 타입 시그니처는
sqr :: (Num a, Num b) => a -> b -- invalid
sqr :: a -> a -- invalid
sqr의 주 타입보다 더 일반적이므로 무효예요.
타입 시그니처는 다형 재귀(polymorphic recursion) 를 지원하는 데에도 사용될 수 있어요. 다음 정의는 병리적이지만, 타입 시그니처가 추론될 타입보다 더 일반적인 타입을 지정하는 데 어떻게 쓰이는지 보여줘요:
data T a = K (T Int) (T a)
f :: T a -> a
f (K x y) = if f x == 1 then f y else undefined
시그니처 선언을 제거하면, f의 타입은 첫 번째 재귀 호출 때문에 — 거기서 f의 인자가 T Int이므로 — T Int -> Int로 추론될 거예요. 다형 재귀는 사용자가 더 일반적인 타입 시그니처 T a -> a를 공급할 수 있게 해줘요.
4.4.2 Fixity 선언
fixity 선언은 하나 이상의 연산자에 결합성(fixity)과 바인딩 우선순위(precedence)를 부여해요. fixity 선언의 정수는 0에서 9 범위여야 해요. fixity 선언은 타입 시그니처가 나타날 수 있는 어디에나 나타날 수 있고, 타입 시그니처처럼 특정 연산자의 속성을 선언해요. 마찬가지로 타입 시그니처처럼, fixity 선언은 연산자 자신의 선언과 같은 선언 열(sequence) 안에서만 나타날 수 있고, 어떤 연산자에 대해서도 fixity 선언은 많아야 하나만 주어질 수 있어요. (클래스 메서드는 작은 예외인데, 그들의 fixity 선언은 클래스 선언 자체나 최상위에 나타날 수 있어요.)
fixity에는 세 종류가 있어요 — 비결합, 왼쪽 결합, 오른쪽 결합(각각 infix, infixl, infixr) — 그리고 0부터 9까지 포함해 열 개의 우선순위 단계가 있어요(단계 0이 가장 느슨하게, 단계 9가 가장 세게 결합해요). 숫자가 생략되면 단계 9로 간주돼요. fixity 선언이 없는 어떤 연산자든 infixl 9로 간주돼요(결합성 사용에 대한 더 자세한 내용은 섹션 3 참고). Table 4.1은 Prelude에 정의된 연산자들의 결합성과 우선순위를 나열해요.
Table 4.1: Prelude 연산자들의 우선순위와 결합성
| 우선순위 | 왼쪽 결합 연산자 | 비결합 연산자 | 오른쪽 결합 연산자 |
|---|---|---|---|
| 9 | !! |
. |
|
| 8 | ^, ^^, ⋆⋆ |
||
| 7 | ⋆, /, `div`, `mod`, `rem`, `quot` |
||
| 6 | +, - |
||
| 5 | :, ++ |
||
| 4 | ==, /=, <, <=, >, >=, `elem`, `notElem` |
||
| 3 | && |
||
| 2 | || |
||
| 1 | >>, >>= |
||
| 0 | $, $!, `seq` |
fixity는 타입처럼 특정 개체(생성자 또는 변수)의 속성이에요. fixity는 그 개체 이름의 속성이 아니에요. 예를 들면:
module Bar( op ) where
infixr 7 'op'
op = ...
module Foo where
import qualified Bar
infix 3 'op'
a 'op' b = (a 'Bar.op' b) + 1
f x = let
p 'op' q = (p 'Foo.op' q) ⋆ 2
in ...
여기서 'Bar.op'은 infixr 7, 'Foo.op'은 infix 3, 그리고 f의 오른쪽에 있는 op의 중첩 정의는 기본 fixity인 infixl 9를 가져요. (중첩 fixity 선언으로 'op'의 중첩 정의에 fixity를 주는 것도 가능해요.)
4.4.3 함수·패턴 바인딩
이 구문 안에서 우리는 두 경우를 구별해요. 왼쪽이 패턴(pat)이면 패턴 바인딩(pattern binding), 그렇지 않으면 함수 바인딩(function binding) 이라고 불러요. 두 바인딩 모두 모듈의 최상위나 where·let 구문 안에 나타날 수 있어요.
4.4.3.1 함수 바인딩
함수 바인딩은 변수를 함수 값에 바인딩해요. 변수 x에 대한 함수 바인딩의 일반적인 형태는 다음과 같아요:
x p11 … p1k match1
…
x pn1 … pnk matchn
여기서 각 pᵢⱼ는 패턴이고, 각 matchᵢ는 다음과 같은 일반적인 형태를 가져요:
= ei where { declsi }
또는
| gsi1 = ei1
…
| gsimi = eimi
where { declsi }
그리고 n ≥ 1, 1 ≤ i ≤ n, mᵢ ≥ 1이에요. 전자는 후자의 특수한 경우의 줄임 표기로 취급돼요. 즉:
| True = ei where { declsi }
함수를 정의하는 모든 절(clause)은 연속해야 하고, 각 절의 패턴 개수도 같아야 한다는 점을 유의하세요. 각 match에 대응하는 패턴의 집합은 선형(linear) 이어야 해요 — 어떤 변수도 전체 집합 안에 두 번 이상 나타나서는 안 돼요.
함수 값을 중위 연산자에 바인딩하는 대안적 구문이 제공돼요. 예를 들어, 다음 세 가지 함수 정의는 모두 동등해요:
plus x y z = x+y+z
x 'plus' y = \ z -> x+y+z
(x 'plus' y) z = x+y+z
fixity 해석(fixity resolution)이 표현식에서와 같은 방식으로 함수 바인딩의 중위 변형에 적용된다는 점을 유의하세요(섹션 10.6). 함수 바인딩의 등호 왼쪽에 fixity 해석을 적용한 결과, 정의되는 varop이 최상위에 남아 있어야 해요. 예를 들어, 우선순위 6의 새 연산자 ##을 정의한다면 다음 정의는 불법이에요:
a ## b : xs = exp
: 는 우선순위 5라서 왼쪽이 (a ## x) : xs로 해석되고, (a ## x)는 유효한 패턴이 아니므로 이것은 패턴 바인딩이 될 수 없기 때문이에요.
번역: 함수에 대한 일반적인 바인딩 형태는 다음 방정식(즉 단순 패턴 바인딩)과 의미상 동등해요:
x = \ x1 … xk -> case (x1, …, xk) of
(p11, …, p1k) match1
…
(pn1, …, pnk) matchn
여기서 xᵢ는 새로운 식별자예요.
4.4.3.2 패턴 바인딩
패턴 바인딩은 변수들을 값에 바인딩해요. 단순 패턴 바인딩은 형태 p = e를 가져요. 패턴 p는 마치 그 앞에 암묵적 ~가 있는 것처럼 반박 불가능한 패턴(irrefutable pattern) 으로 "느긋하게(lazily)" 매칭돼요. 섹션 3.12의 번역을 참고하세요.
패턴 바인딩의 일반적인 형태는 p match이며, 여기서 match는 함수 바인딩과 같은 구조예요. 다시 말해, 패턴 바인딩은:
p | gs1 = e1
| gs2 = e2
…
| gsm = em
where { decls }
번역: 위 패턴 바인딩은 다음 단순 패턴 바인딩과 의미상 동등해요:
p = let decls in
case () of
() | gs1 -> e1
| gs2 -> e2
…
| gsm -> em
_ -> error "Unmatched pattern"
4.5 함수·패턴 바인딩의 정적 의미론
이 절에서는 let 표현식이나 where 절의 함수·패턴 바인딩의 정적 의미론을 다뤄요.
4.5.1 의존성 분석
일반적으로 정적 의미론은 평범한 Hindley-Milner 추론 규칙을 적용해서 주어져요. 다형성을 높이기 위해, 이 규칙들은 의존성 분석(dependency analysis) 으로 식별된 바인딩 그룹들에 적용돼요.
같은 선언 목록 안에서 바인딩 b₁이 바인딩 b₂에 의존한다고 말하는 것은, 다음 중 하나가 성립할 때예요:
- b₁이 타입 시그니처가 없고 b₂에 의해 바인딩된 자유 식별자를 포함하거나,
- b₁이 b₂에 의존하는 어떤 바인딩에 의존한다.
선언 그룹(declaration group) 은 서로 의존하는 바인딩들의 최소 집합이에요. Hindley-Milner 타입 추론은 각 선언 그룹에 의존성 순서대로 적용돼요. where/let 구문 안 선언들의 순서는 무관해요.
4.5.2 일반화 (Generalization)
Hindley-Milner 타입 시스템은 let 표현식에 두 단계로 타입을 부여해요:
- 선언 그룹들은 의존성 순서로 고려돼요. 각 그룹에 대해, 전역 양화가 없는 타입이 그 그룹에 바인딩된 각 변수에 대해 추론돼요. 그 다음, 이 타입들에 나타나는 모든 타입 변수는 — 타입 환경의 바인딩 변수와 연관되지 않는 한 — 전역 양화돼요. 이것을 일반화(generalization) 라고 불러요.
- 마지막으로, let 표현식의 본문이 타이핑돼요.
예를 들어, 선언을 생각해볼게요:
f x = let g y = (y,y)
in ...
g 정의의 타입은 a -> (a,a)예요. 일반화 단계는 g에 다형 타입 ∀ a. a -> (a,a)를 부여해요. 그 후 "..." 부분의 타이핑이 진행될 수 있어요.
오버로드된 정의를 타이핑할 때, 단일 선언 그룹의 모든 오버로딩 제약은 함께 모여 그 그룹에 선언된 각 변수의 타입에 대한 컨텍스트를 형성해요. 예를 들어, 다음 정의에서:
f x = let g1 x y = if x>y then show x else g2 y x
g2 p q = g1 q p
in ...
g1과 g2 정의의 타입은 모두 a -> a -> String이고, 누적된 제약은 Ord a(의 사용에서 비롯)와 Show a(show의 사용에서 비롯)예요. 이 제약 모음에 나타나는 타입 변수를 제약된 타입 변수(constrained type variables) 라고 불러요.
일반화 단계는 g1과 g2 둘 다에 다음 타입을 부여해요:
∀ a. (Ord a, Show a) ⇒ a -> a -> String
>와 show의 발생이 g1의 정의에 있음에도 g2가 g1과 같은 방식으로 오버로드된다는 점을 주목하세요.
프로그래머가 한 선언 그룹의 하나보다 많은 변수에 명시적 타입 시그니처를 공급하면, 그 시그니처들의 컨텍스트는 타입 변수의 이름 바꾸기까지 고려해 동일해야 해요.
4.5.3 컨텍스트 축약 오류
섹션 4.1.4에서 언급했듯, 타입의 컨텍스트는 타입 변수, 또는 타입 변수에 하나 이상의 타입을 적용한 것만 제약할 수 있어요. 따라서 일반화가 만들어내는 타입은 모든 컨텍스트 제약이 이 "머리 정규형(head normal form)"으로 축약된 형태로 표현돼야 해요. 예를 들어, 정의를 생각해볼게요:
f xs y = xs == [y]
그 타입은
f :: Eq a => [a] -> a -> Bool
로 주어지고,
f :: Eq [a] => [a] -> a -> Bool
이 아니에요. 동등성이 리스트 타입에서 취해지더라도, 일반화 전에 컨텍스트를 리스트에 대한 Eq의 인스턴스 선언을 사용해 단순화해야 해요. 그런 인스턴스가 스코프에 없으면 정적 오류가 발생해요.
여기 C (m t) 형태의 제약이 필요한 예가 있어요. 여기서 m은 일반화되는 타입 변수 중 하나, 즉 클래스 C가 타입 변수나 타입 생성자가 아닌 타입 표현식에 적용되는 경우예요. 다음을 생각해볼게요:
f :: (Monad m, Eq (m a)) => a -> m a -> Bool
f x y = return x == y
return의 타입은 Monad m => a -> m a, (==)의 타입은 Eq a => a -> a -> Bool이에요. 따라서 f의 타입은 (Monad m, Eq (m a)) => a -> m a -> Bool이어야 하고, 컨텍스트는 더 이상 단순화될 수 없어요.
data 타입의 deriving 절에서 파생된 인스턴스 선언(섹션 4.3.3 참고)은 어떤 인스턴스 선언이든 단순 컨텍스트를 가져야 해요. 즉, 모든 제약이 C a 형태여야 하고, 여기서 a는 타입 변수예요. 예를 들어, 타입
data Apply a b = App (a b) deriving Show
에서 파생된 Show 인스턴스는 축약될 수 없고 단순하지도 않은 컨텍스트 Show (a b)를 만들어내므로 정적 오류가 발생해요.
4.5.4 단형성 (Monomorphism)
때로는 정의 타입에 사용된 모든 타입 변수에 대해 일반화하는 것이 불가능해요. 예를 들어, 선언을 생각해볼게요:
f x = let g y z = ([x,y], z)
in ...
x가 타입 a를 가진 환경에서, g 정의의 타입은 a -> b -> ([a],b)예요. 일반화 단계는 g에 타입 ∀ b. a -> b -> ([a],b)를 부여해요. a가 타입 환경에 나타나므로 b만 전역 양화될 수 있어요. 우리는 g의 타입이 타입 변수 a에 대해 단형(monomorphic) 이라고 말해요.
이런 단형성의 효과는 g가 적용되는 모든 곳에서 g의 첫 인자가 단일 타입이어야 한다는 거예요. 예를 들어, "..."가 다음과 같다면 유효해요:
(g True, g False)
(이것은 부수적으로 x의 타입을 Bool로 강제해요.) 하지만 다음과 같다면 무효해요:
(g True, g 'c')
일반적으로 타입 ∀ u. cx ⇒ t는, a가 ∀ u. cx ⇒ t에 자유로 나타나면, 타입 변수 a에 대해 단형이라고 말해요.
Haskell이 제공하는 명시적 타입 시그니처는 단형 타입 변수를 포함하는 타입을 표현할 만큼 강력하지 않다는 점을 주목할 가치가 있어요. 예를 들어, 우리는 다음과 같이 쓸 수 없어요:
f x = let
g :: a -> b -> ([a],b)
g y z = ([x,y], z)
in ...
왜냐하면 그것은 g가 a와 b 둘 다에 다형적이라고 주장하는 것이기 때문이에요(섹션 4.4.1). 이 프로그램에서 g는, 첫 인자가 타입 변수를 포함하지 않는 타입으로 제한될 때에만 타입 시그니처를 줄 수 있어요. 예를 들면:
g :: Int -> b -> ([Int],b)
이 시그니처는 또한 x의 타입을 Int로 만들 거예요.
4.5.5 단형성 제약 (The Monomorphism Restriction)
Haskell은 위에서 설명한 표준 Hindley-Milner 제약에 더해 일반화 단계에 몇 가지 추가 제약을 두는데, 이것은 특정한 경우에 다형성을 더 줄여줘요.
단형성 제약은 변수의 바인딩 구문에 의존해요. 변수는 함수 바인딩이나 패턴 바인딩으로 바인딩된다는 것, 그리고 단순 패턴 바인딩(simple pattern binding)은 패턴이 오직 단일 변수로만 구성된 패턴 바인딩이라는 것을 상기해요(섹션 4.4.3).
다음 두 규칙이 단형성 제약을 정의해요.
단형성 제약
규칙 1. 주어진 선언 그룹이 비제약(unrestricted) 인 것은 다음의 경우에만 해당한다고 말해요:
- (a): 그룹의 모든 변수가 함수 바인딩이나 단순 패턴 바인딩(섹션 4.4.3.2)으로 바인딩되고,
- (b): 단순 패턴 바인딩으로 바인딩된 그룹의 모든 변수에 대해 명시적 타입 시그니처가 주어진다.
보통의 Hindley-Milner 다형성 제약은, 환경에 자유로 나타나지 않는 타입 변수만 일반화될 수 있다는 것이다. 게다가, 제약된 선언 그룹의 제약된 타입 변수들은 그 그룹에 대한 일반화 단계에서 일반화되지 못할 수 있다. (타입 변수가 어떤 타입 클래스에 속해야 한다면 그 변수는 제약됐다고 상기해요. 섹션 4.5.2 참고.)
규칙 2. 전체 모듈에 대한 타입 추론이 완료되었을 때 남아 있는 어떤 단형 타입 변수든 모호한 것으로 간주되고, 기본값 규칙(섹션 4.3.4)을 사용해 특정 타입으로 해결된다.
동기 (Motivation)
규칙 1은 두 가지 이유로 필요한데, 둘 다 상당히 미묘해요.
-
규칙 1은 계산이 예상치 못하게 반복되는 것을 막아줘요. 예를 들어,
genericLength는 (라이브러리Data.List의) 표준 함수로서 그 타입은 다음과 같이 주어져요:genericLength :: Num a => [b] -> a이제 다음 표현식을 생각해볼게요:
let { len = genericLength xs } in (len, len)len은 한 번만 계산될 것처럼 보이지만, 규칙 1이 없으면 서로 다른 두 오버로딩에서 각각 한 번씩, 두 번 계산될 수 있어요. 프로그래머가 실제로 계산의 반복을 원한다면 명시적 타입 시그니처를 추가할 수 있어요:
let { len :: Num a => a; len = genericLength xs } in (len, len) -
규칙 1은 모호성을 막아줘요. 예를 들어, 선언 그룹을 생각해볼게요:
[(n,s)] = reads treads는 시그니처가 다음과 같이 주어진 표준 함수라는 것을 상기해요:reads :: (Read a) => String -> [(a,String)]규칙 1이 없으면 n에는 타입
∀ a. Read a ⇒ a가, s에는 타입∀ a. Read a ⇒ String이 부여될 거예요. 후자는 본질적으로 모호해서 무효한 타입이에요. s를 어떤 오버로딩으로 사용할지 결정할 수 없고, s에 타입 시그니처를 추가해도 해결되지 않아요. 따라서 단순하지 않은(non-simple) 패턴 바인딩(섹션 4.4.3.2)이 사용되면, 추론된 타입은 타입 시그니처가 제공되든 말든 항상 그들의 제약된 타입 변수에 대해 단형이에요. 이 경우 n과 s 둘 다 a에 대해 단형이에요.같은 제약이 패턴으로 바인딩된 함수에도 적용돼요. 예를 들어,
(f,g) = ((+),(-))에서 f에 대해 제공된 타입 시그니처가 있든 없든 f와 g 모두 단형이에요.
규칙 2는, 현재 모듈 밖에서 타입 추론을 수행하지 않고서는 export된 바인딩의 단형 사용을 강제할 방법이 없기 때문에 필요해요. 규칙 2는 모듈에 바인딩된 모든 변수의 정확한 타입은, 그것을 import하는 어떤 모듈이 아니라 그 모듈 단독으로 결정되어야 한다고 말해요.
module M1(len1) where
default( Int, Double )
len1 = genericLength "Hello"
module M2 where
import M1(len1)
len2 = (2⋆len1) :: Rational
모듈 M1에 대한 타입 추론이 완료되면, len1은 (규칙 1에 의해) 단형 타입 Num a => a를 가져요. 규칙 2는 이제 단형 타입 변수 a가 모호하고 섹션 4.3.4의 기본값 규칙으로 해결되어야 한다고 말해요. 따라서 len1은 타입 Int를 얻고, len2에서의 그 사용은 타입적으로 잘못돼요. (위 코드가 실제로 원하는 것이라면, len1에 타입 시그니처를 두면 문제가 해결돼요.)
이 문제는 중첩 바인딩에서는 발생하지 않는데, 그들의 전체 스코프가 컴파일러에게 보이기 때문이에요.
결과 (Consequences)
단형성 규칙은 프로그래머에게 여러 결과를 가져와요. 함수 구문으로 정의된 것은 보통 함수가 기대하는 대로 일반화돼요. 따라서
f x y = x+y
에서 함수 f는 클래스 Num의 어떤 오버로딩에서든 사용될 수 있어요. 여기에는 재계산의 위험이 없어요. 하지만 같은 함수를 패턴 구문으로 정의하면:
f = \x -> \y -> x+y
f가 완전히 오버로드되려면 타입 시그니처가 필요해요. 많은 함수가 단순 패턴 바인딩으로 정의하는 것이 가장 자연스러운데, 사용자는 완전한 오버로딩을 유지하기 위해 이것들에 타입 시그니처를 붙이는 데 주의해야 해요. 표준 prelude에는 이에 대한 많은 예가 있어요:
sum :: (Num a) => [a] -> a
sum = foldl (+) 0
규칙 1은 최상위 정의와 중첩 정의 둘 다에 적용돼요. 다음을 생각해볼게요:
module M where
len1 = genericLength "Hello"
len2 = (2⋆len1) :: Rational
여기서 타입 추론은 len1이 단형 타입 (Num a => a)을 가진다는 것을 찾아내고, 타입 변수 a는 len2에 대한 타입 추론을 수행할 때 Rational로 해결돼요.
4.6 종류 추론
이 절에서는 종류 추론을 수행하는 규칙을 설명해요. 종류 추론이란 주어진 프로그램에 나타나는 각 타입 생성자와 클래스에 적절한 종류를 계산하는 작업이에요.
종류 추론 과정의 첫 단계는 데이터 타입·동의어·클래스 정의들의 집합을 의존성 그룹(dependency groups) 으로 정렬하는 것이에요. 이것은 섹션 4.5에서 설명한 값 선언에 대한 의존성 분석과 거의 같은 방식으로 달성할 수 있어요. 예를 들어, 다음 프로그램 조각은 데이터 타입 생성자 D, 동의어 S, 클래스 C의 정의를 포함하는데, 이들은 모두 같은 의존성 그룹에 포함돼요:
data C a => D a = Foo (S a)
type S a = [D a]
class C a where
bar :: a -> D a -> Bool
각 그룹 안의 변수·생성자·클래스의 종류는 타입 추론과 종류 보존 통일(kind-preserving unification)의 표준 기법으로 결정돼요 [8]. 예를 들어, 위 정의들에서 매개변수 a는 bar의 타입에서 함수 생성자 (->)의 인자로 나타나므로 종류 ∗를 가져야 해요. 따라서 D와 S 둘 다 종류 ∗→∗를 가져야 하고, 클래스 C의 모든 인스턴스는 종류 ∗를 가져야 해요.
추론된 종류의 일부가 대응하는 정의에 의해 완전히 결정되지 않을 수도 있어요. 그런 경우 기본값 ∗가 가정돼요. 예를 들어, 다음 각 예에서 a 매개변수에 임의의 종류 κ를 가정할 수 있어요:
data App f a = A (f a)
data Tree a = Leaf | Fork (Tree a) (Tree a)
이것은 App과 Tree에 대해, 임의의 종류 κ에 대해 각각 (κ →∗) → κ →∗와 κ →∗의 종류를 주게 되는데, 이는 다형 종류를 허용하는 확장을 요구해요. 대신 기본 바인딩 κ = ∗를 사용하면, 이 두 생성자의 실제 종류는 각각 (∗→∗) →∗→∗와 ∗→∗가 돼요.
기본값은, 특정 타입 생성자 상수나 클래스가 나중 의존성 그룹이나 프로그램의 다른 곳에서 사용되는 방식을 고려하지 않고, 각 의존성 그룹에 적용돼요. 예를 들어, 다음 정의를 위 것들에 추가해도 Tree에 대해 추론된 종류에 영향을 주지 않고(예를 들어 (∗→∗) →∗로 바꾸지 않고), 대신 []의 종류인 ∗→∗가 Tree의 인자에 기대되는 종류 ∗와 맞지 않으므로 정적 오류를 생성해요:
type FunnyTree = Tree [] -- invalid
이것은 각 생성자와 클래스가 스코프에 있을 때마다 항상 같은 종류로 일관되게 사용되도록 보장하기 때문에 중요해요.
더 알아보기 (Learn more)
- 챕터 3 (Expressions): 패턴 매칭, 레코드 구문
C { ... }, 표현식 타입 시그니처 등 선언과 밀접하게 얽힌 표현식 문법을 다뤄요. - 챕터 5 (Modules):
topdecls가 실제로 놓이는 최상위 모듈 구조와 import/export, 한정된 이름의 사용을 설명해요. - 챕터 6 (Prelude): 이 장에서 언급된 내장 데이터 타입과
$!,asTypeOf같은 함수들의 정의를 확인할 수 있어요. - 챕터 9 (Standard Prelude):
read·show·reads·genericLength등 표준 함수와 클래스 인스턴스의 실제 예시가 있어요. - 챕터 11 (Specification of Derived Instances):
Eq,Ord,Enum,Bounded,Show,Read클래스의 파생 인스턴스가 정확히 어떻게 생성되는지 명세해요. - 관련 논문: 타입 클래스의 원래 설계는 Wadler & Blott (1989), constructor class 확장은 Mark P. Jones의 논문에서 더 깊이 다룹니다.