계정: 로그인
AA 📝
8. 표준 Haskell 클래스

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


이 장에서는 Haskell에 미리 정의되어 있는 표준 타입 클래스들을 소개합니다. 클래스 속의 메서드들 중 별로 흥미를 끌지 못할만한 것들을 생략함으로써 이 클래스들을 단순화시켰습니다. (Haskell Report에는 빠짐없는 완벽한 설명이 들어있습니다.) 그리고 표준 클래스 중 몇몇은 Haskell 표준 라이브러리의 일부이며, 이런 것들은 Library Report에서 설명하고 있습니다.

8.1 동일성과 순서가 있는 클래스들

클래스 EqOrd에 관해서는 이미 논의했습니다. Prelude에 있는 Ord의 정의는 전에 보여드린 간략한 버전보다 좀 더 복잡합니다. 특히, 다음과 같은 compare 메서드에 주목하십시오:

   1 data  Ordering    =  EQ | LT | GT 
   2 compare           :: Ord a => a -> a -> Ordering

메서드 compare는 이 클래스 내에 있는 다른 모든 메서드들을 (디폴트를 통해) 정의하기에 충분하며, Ord의 인스턴스들을 만드는 가장 좋은 방법입니다.

8.2 나열형 (Enumeration) 클래스

클래스 Enum에는 산술적 시퀀스를 위한 syntactic sugar의 기반이 되는 연산들의 집합이 들어있습니다. 예를 들어, 산술 시퀀스 표현식인 [1,3..]enumFromThen 1 3을 의미합니다. (정형적인 해설은 §3.10을 참조하시기 바랍니다.) 산술 시퀀스 표현식으로 Enum의 인스턴스인 모든 타입의 리스트를 만들 수 있음을 알 수 있습니다. 여기에는 대부분의 수치형 타입들 뿐만 아니라 Char도 포함되며, 따라서 ['a'..'z']는 알파벳 순서로 나열된 영문 소문자들의 리스트를 나타냅니다. 게다가, Color 같은 사용자정의 나열형 타입도 쉽게 Enum 인스턴스 선언을 가질 수 있습니다. 이렇게 되면 다음과 같은 일이 벌어집니다:

비록 값이 수치는 아니지만, 값 사이의 증분이 상수라는 점에서 이런 시퀀스는 산술적(arithmetic)이라는 사실에 주의하시기 바랍니다. Enum 계열의 타입들 대부분은 고정 정밀도 정수에 대응될 수 있습니다: 이를 위해서 fromEnumtoEnumInt 타입과 Enum에 속하는 타입 간의 전환을 맡고 있습니다.

8.3 Read 클래스와 Show 클래스

클래스 Show의 인스턴스들은 (대개 입출력을 위한) 문자열로 바뀔 수 있는 그런 타입들입니다. 클래스 Read는 문자열을 파싱해서 그 문자열이 의미하는 값을 얻기 위한 연산들을 제공합니다. 클래스 Show에 들어있는 가장 간단한 함수는 show입니다:

아주 자연스러운 일이지만, show는 적절한 타입의 값을 아무거나 하나 받아서 그 값의 표시형태를 문자열(문자의 리스트)로 리턴합니다. 즉, show (2+2)의 결과는 "4"입니다. 여기까진 그런대로 좋습니다만, 아래 경우처럼 여러가지 값들을 표시해야 하는 복합 문자열을 만들게되는 일이 다반사며:

   1 "The sum of " ++ show x ++ " and " ++ show y ++ " is " ++ show (x+y) ++ "."

이런 경우 위에 쓰인 모든 결합 연산자들은 어찌보면 조금 비효율적입니다. 구체적인 예로, 2.2.1절에 나왔던 이진 트리를 표시하는 함수를 생각해봅시다. 여기에는 서브트리의 중첩과 좌우 가지의 분리를 표현하는 적절한 표시가 있습니다. (트리를 구성하는 원소의 타입은 문자열로 표시 가능한 타입이라고 가정합니다):

   1 showTree                 :: (Show a) => Tree a -> String
   2 showTree (Leaf x)        =  show x
   3 showTree (Branch l r)    =  "<" ++ showTree l ++ "|" ++ showTree r ++ ">"

(++)는 좌측 인수의 길이에 1차 선형 비례하는 시간 복잡도를 갖기 때문에, showTree는 트리의 크기에 대해 잠재적으로 2차 승수로 비례합니다.

선형 복잡도를 복구하기 위해, 함수 shows가 제공됩니다:

shows는 인쇄 가능한 값 하나와 문자열 하나를 인수로 받아서 그 문자열의 앞부분에 값의 표현형을 결합한 문자열을 리턴합니다. 두번째 인수는 문자열 누적체의 일종으로 작동하며, 이제 show는 빈(null) 문자열 누적체를 갖는 shows를 사용해서 정의할 수 있습니다. 이것이 클래스 Show 정의에 들어있는 show의 기본 정의입니다:

   1 show x    =  shows x ""

역시 문자열 누적체를 인수로 갖는 좀 더 효율적인 버전의 showTree를 정의하기 위해 shows를 사용할 수 있습니다:

   1 showsTree                   :: (Show a) => Tree a -> String -> String
   2 showsTree (Leaf x) s        =  shows x s
   3 showsTree (Branch l r) s    =  '<' : showsTree l ('|' : showsTree r ('>' : s))

이렇게 하면 효율성 문제는 해결이 됩니다만 (showsTree는 선형 복잡도를 갖습니다), 이 함수(혹은 이와 비슷한 다른 함수들)의 표현은 더 개선될 수 있습니다. 첫째, 타입 동의어를 하나 만듭시다:

   1 type  ShowS    =  String -> String

이것은 문자열 누적체 앞에 나오는 어떤 대상의 문자열 표현형을 리턴하는 함수의 타입입니다. 둘째, 함수형 합성법을 사용함으로써, 문자열 누적체를 여기저기 가지고 돌아다니지 않을 수도 있고, 길게 중복되는 구성자 사용시 끝부분에 괄호가 쌓이지 않게 할 수도 있습니다:

   1 showsTree                 :: (Show a) => Tree a -> ShowS
   2 showsTree (Leaf x)        =  shows x
   3 showsTree (Branch l r)    =  ('<':) . showsTree l . ('|':) . showsTree r . ('>':)

이런 변형에 의해서, 단순히 코드를 정돈하는 것 이상으로 중요한 사실 하나가 드러납니다: 우리는 표현 수준을 객체 수준(이 경우에는 문자열)에서 함수 수준으로 끌어올린 것입니다. showsTree의 타입은 트리 하나를 전시 (showing) 함수 하나에 대응시키는 타입이라고 생각할 수 있습니다. ('<' :)("a string" ++) 같은 함수들은 원시 전시 함수 (primitive showing functions)이며, 함수 합성에 의해서 좀 더 복합적인 함수들을 만들 수 있습니다.

트리를 문자열로 바꿀 수는 있게 되었으니, 이제는 그 반대로 바꿔봅시다. 기본적인 발상은 어떤 타입 a에 대한 파서이며, 이것은 문자열 하나를 받아서 (a, String) 페어들의 리스트를 리턴하는 함수입니다. [9]. Prelude는 이런 함수에 대한 타입 동의어를 제공합니다:

   1 type  ReadS a    =  String -> [(a,String)]

정상적인 경우에는, 파서는 입력 문자열로부터 읽어들인 a 타입의 값 하나와 파싱된 부분 뒤에 따라나오는 나머지 문자열을 포함하는 단일 원소 리스트를 리턴합니다. 하지만, 만일 파싱이 전혀 불가능한 경우라면 결과는 빈 리스트가 될 것이고, 만일 파싱할 수 있는 방법이 둘 이상 존재하는 (모호한) 경우라면 결과는 둘 이상의 페어를 담은 리스트가 될 것입니다. 표준 함수 reads는 클래스 Read의 모든 인스턴스에 대한 파서입니다:

showsTree가 만든 이진 트리의 문자열 표현형을 파싱하는 함수를 정의하는 데에 상기 함수를 사용할 수 있습니다. 이런 파서를 구성하는 데에 있어서 list comprehension이 편리한 숙어를 제공합니다 (모나드와 파서 결합자를 사용하는 더욱 우아한 파싱 방법이 있습니다. 이런 것들은 대부분의 Haskell 시스템에 포함되어 함께 배포되는 표준 파싱 라이브러리에 들어있습니다):

   1 readsTree            :: (Read a) => ReadS (Tree a)
   2 readsTree ('<':s)    =  [(Branch l r, u) | (l, '|':t) <- readsTree s,
   3                                            (r, '>':u) <- readsTree t ]
   4 readsTree s          =  [(Leaf x, t) | (x,t) <- reads s]

잠시 이 함수 정의에 대해서 자세히 살펴봅시다. 고려해볼만한 주된 상황이 두 가지 있습니다: 파싱될 문자열의 첫 문자가 '<'이면 가지의 표현을 받아들여야 하고, 아니면 말단 노드를 받아들여야 합니다. 첫번째 경우에는, 입력 문자열 중 여는 각괄호(<) 다음에 따라나오는 나머지 부분인 s를 호출함으로써, 파싱 결과는 다음과 같은 조건 하에 문자열 u를 나머지로 하는 트리 Branch l r이어야 합니다:

  1. 문자열 s의 시작 부분으로부터 트리 l이 파싱될 수 있습니다.

  2. (l의 표현형에 뒤따라 나오는) 나머지 문자열은 '|'로 시작합니다. 이 문자열의 tail 부분을 t라고 부릅니다.

  3. t의 시작 부분으로부터 트리 r이 파싱될 수 있습니다.

  4. 이 파싱으로부터 남겨지는 나머지 문자열의 첫 문자는 '>'이고, 다시 그 나머지는 u입니다.

패턴 매칭을 list comprehension과 결합하여 얻을 수 있는 표현력에 주목하시기 바랍니다: 파싱 결과물의 형태는 list comprehension의 주 표현식에 의해 결정되고, 상기 조건들 중 처음 두 가지는 첫번째 생성자로 표현되며 ((l, '|':t)s의 파싱 결과물들의 리스트로부터 얻을 수 있습니다), 나머지 조건들은 두번째 생성자로 표현됩니다.

상기 정의문들 중 두번째 등식이 말하고 있는 것은, 말단 노드의 표현형을 파싱하려면 트리를 구성하는 원소들의 타입의 표현형을 파싱하고, 이렇게해서 얻는 값에 구성자 Leaf를 적용해야 한다는 것입니다.

이제 (다른 여러가지 타입들도 마찬가지지만 그 중에서) Integer 타입의 Read (그리고 Show도) 인스턴스가 존재한다는 사실을 충분히 받아들일 수 있습니다. 이 인스턴스는 예상대로 제대로 동작하는 reads를 제공합니다. 즉:

이걸 이해하면, 여러분은 이제 다음과 같은 평가 과정을 스스로 학인해볼 수 있습니다:

아까 정의한 readsTree의 정의에는 두 가지 단점이 있습니다. 하나는 파서가 너무 융통성이 없어서 트리 표현형의 원소들 앞이나 사이에 공백을 전혀 허용하지 않는다는 점이고, 다른 하나는 구두점 기호들을 파싱하는 방법이 말단 노드 값이나 서브트리를 파싱하는 방법과 너무 동떨어져있어서 이런 일관성 부족 때문에 함수 정의를 읽기가 힘들어진다는 점입니다. Prelude에서 제공하는 어휘분석기를 써서 이 두 가지 문제들을 모두 짚어볼 수 있습니다:

함수 lex는 보통 두 문자열들의 쌍 하나를 담은 단일 원소 리스트를 리턴합니다: 입력 문자열의 첫번째 어휘소와 그 나머지가 바로 그것입니다. 어휘 규칙(lexical rules)은 공백 문자들과 lex가 건너뛰는 주석을 포함하는 Haskell 프로그램의 어휘 규칙입니다. 만일 입력 문자열이 빈 문자열이거나 공백 문자 혹은 주석만을 포함한다면, lex[("","")]를 리턴합니다. 만일 입력 문자열이 이런 의미에서 빈 문자열은 아니지만 선행 공백 (혹은 주석) 이후 첫 부분이 유효한 어휘소(lexeme)로 시작되지 않는다면, lex[]를 리턴합니다.

어휘분석기를 쓰면 아까전의 트리 파서는 다음과 같이 바뀝니다:

   1 readsTree      :: (Read a) => ReadS (Tree a)
   2 readsTree s    =  [(Branch l r, x) | ("<", t) <- lex s,
   3                                      (l,   u) <- readsTree t,
   4                                      ("|", v) <- lex u,
   5                                      (r,   w) <- readsTree v,
   6                                      (">", x) <- lex w        ]
   7                   ++
   8                   [(Leaf x, t)     | (x,   t) <- reads s      ]

이제 readsTreeshowsTree를 사용해서 (Read a) => Tree aRead의 인스턴스로, (Show a) => Tree aShow의 인스턴스로 선언하고 싶을겁니다. 이렇게 하면 트리를 파싱하고 표시하는 데에 Prelude의 일반적인 오버로드된 함수들을 쓸 수 있게 됩니다. 게다가, 자동적으로 [Tree Integer] 처럼 트리를 구성 원소로 포함하는 다른 많은 타입들을 파싱하고 표시할 수 있게 됩니다. 결국, readsTreeshowsTreeShowRead의 메서드로서 올바른 타입입니다. showsPrec 메서드와 readsPrec 메서드는 showsreads의 매개변수화된 버전입니다. 추가적인 매개변수는 중위 구성자를 포함하는 표현식을 적절하게 괄호로 묶기 위해 사용하는 우선 순위입니다. Tree 같은 타입들에 대해서는 우선 순위가 무시될 수 있습니다. Tree에 대한 ShowRead의 인스턴스는 다음과 같습니다:

   1 instance  Show a => Show (Tree a)  where
   2     showsPrec _ x    =  showsTree x
   3 
   4 instance  Read a => Read (Tree a)  where
   5     readsPrec _ s    =  readsTree s

또는 변칙적으로, showTree를 사용해서 Show의 인스턴스를 정의할 수 있습니다:

   1 instance Show a => Show (Tree a) where
   2     show t    =  showTree t

그러나 이것은 ShowS 버전보다 비효율적입니다. 클래스 ShowshowsPrecshow 모두에 대한 디폴트 메서드를 정의하며, 사용자로 하여금 인스턴스 선언 내부에서 이 두가지 중 하나를 정의할 수 있게끔 해준다는 사실을 명심하시기 바랍니다. 이 디폴트들은 상호 재귀적이기 때문에, 이 두 함수들 중 아무것도 정의하지 않고 있는 인스턴스 선언은 호출되면 무한루프에 빠집니다. Num 같은 다른 클래스들도 이런 ‘서로 맞물린 디폴트’들을 가지고 있습니다.

클래스 ReadShow에 대해서 더 자세히 알고 싶은 독자는 §D를 보시기 바랍니다.

(함등 함수여야만 하는) (read . show)를 어떤 트리에 적용함으로써 ReadShow의 인스턴스를 검사해볼 수 있으며, 여기서 readreads의 특수한 경우입니다:

만일 교유한 파싱 결과가 없거나 입력 중에 a 타입의 값 하나에 대한 표현형 이외의 어떤 것 (그리고 주석이나 공백 문자들)이 들어있으면 이 함수는 옳게 동작하지 않습니다.

8.4 파생 인스턴스 (Derived Instances)

5장에서 보여드린 트리의 Eq 인스턴스를 상기하시기 바랍니다. 이런 선언은 만들기 쉽고 심지어 지겹기까지 합니다. 말단 노드의 원소의 타입은 동치 비교가 가능한 타입이어야 하고, 두 개의 말단 노드 속에 같은 원소들이 들어있으면 (그리고 들어있는 경우에만) 두 말단 노드가 같으며, 두 가지의 좌우 서브트리들이 각각 서로 같으면 (그리고 같은 경우에만) 두 가지가 같습니다. 그렇지 않은 다른 모든 트리들은 서로 다릅니다:

   1 instance  (Eq a) => Eq (Tree a)  where
   2     Leaf x     == Leaf y          =  x == y
   3     Branch l r == Branch l' r'    =  l == l' && r == r'
   4     _          == _               =  False

다행스럽게도, 새로운 타입에 대해서 동치 비교 연산자가 필요할 때마다 항상 이런 지겨운 작업을 반복할 필요는 없습니다. Eq 인스턴스는 그렇게 되도록 명시하기만 하면 data 선언으로부터 자동적으로 파생될 수 있습니다:

   1 data  Tree a    =  Leaf a | Branch (Tree a) (Tree a)   deriving Eq

deriving 절은 암묵적으로 5장에서 나온 것과 같은 Eq 인스턴스 선언을 만듭니다. Ord, Enum, Ix, Read, 그리고 Show의 인스턴스들도 deriving 절로 만들 수 있습니다.

둘 이상의 클래스 이름을 명시할 수 있으며, 이 경우 이름들의 목록은 괄호로 묶어야 하고 이름들 사이는 쉼표로 분리해야 합니다.

Tree에 대한 파생 Ord 인스턴스는 Eq 인스턴스보다 약간 더 복잡합니다:

   1 instance  (Ord a) => Ord (Tree a)  where
   2     Leaf _     <= Branch _        =  True
   3     Leaf x     <= Leaf y          =  x <= y
   4     Branch _   <= Leaf _          =  False
   5     Branch l r <= Branch l' r'    =  l == l' && r <= r' || l <= l'

이것은 사전 편찬식 (lexicographic) 순서를 서술합니다: 구성자들은 data 선언문에서 나타나는 순서대로 정렬되며, 구성자의 인수는 왼쪽에서 오른쪽으로 비교됩니다. 내장 리스트 타입은 평범한 2-구성자 타입과 의미론상 동일하다는 사실을 상기하십시오. 사실, 전체 선언은 다음과 같습니다:

   1 data  [a]    =  [] | a : [a]   deriving (Eq, Ord)    -- pseudo-code

(파생된건 아니지만 리스트에는 ShowRead 인스턴스도 존재합니다.) 리스트의 파생된 EqOrd 인스턴스들은 일상적인 것들입니다: 구체적인 경우로, 문자열은 문자들의 리스트로서, 근간이 되는 Char 타입을 기준으로 하는 결정에 따라 순서를 매기며, 예를 들어 "cat" < "catalog"에서처럼 긴 문자열보다는 그 문자열의 앞머리쪽 부분 문자열이 더 작다고 간주됩니다.

대개의 경우 EqOrd 인스턴스들은 사용자 정의 인스턴스이기보다는 거의 항상 파생된 인스턴스입니다. 사실, 우리는 동치 관계와 전체 순서에 대한 대수학적 속성을 유지할 수 있도록 주의하면서, 동치와 순서에 관한 자신만의 술어(predicate)들을 스스로 조심스럽게 제공해야만 합니다. 예를 들어, transitive하지 않은 (a == b이고 b == c라고 해도 반드시 a == c라고는 할 수 없는) 술어 (==)은 이 술어가 정의적 동일성에 가깝다는 점에 의존하는 수동 혹은 자동 프로그램 변환을 혼란스럽게 하고, 프로그램을 읽는 사람을 헛갈리게 할 수 있습니다. 그럼에도 불구하고, 가끔은 파생되어 나오는 것과는 다른 EqOrd 인스턴스를 제공할 필요가 있습니다: 아마 가장 중요한 예는 서로 다른 concrete한 값들이 같은 추상적 값을 나타내는 추상 자료형이 아닐까 합니다.

나열형 타입은 파생된 Enum 인스턴스를 가질 수 있고, 순서는 data 선언에 나타나는 구성자들의 순서를 따릅니다. 예를 들어:

   1 data  Day    =  Sunday | Monday | Tuesday | Wednesday
   2              |  Thursday | Friday | Saturday         deriving (Enum)

이 타입에 대해 파생 인스턴스를 사용하는 간단한 예제는 다음과 같습니다:

파생 Read (혹은 Show) 인스턴스는 컴포넌트 타입이 Read (혹은 Show) 인스턴스를 갖는 모든 타입들에 대해서 가능합니다. (Prelude는 대부분의 표준 타입들에 대해서 ReadShow 인스턴스를 제공합니다. 함수 타입인 (->) 같은 몇몇 타입들은 Show 인스턴스는 가지고 있지만 그에 상응하는 Read 인스턴스는 없습니다.) 파생 Show 인스턴스에 의해 정의되는 표현 양식은 문제의 타입이 상수형 Haskell 표현식으로 나타날 때의 형태와 상통합니다. 예를 들어, 만일 상기 Day 타입의 deriving 절에 ShowRead를 추가하면 다음과 같은 결과를 얻게됩니다:


이전 다음 위로
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.