INDIES

[Learn You a Haskell For Great Good!] 3. 타입과 타입 클래스 본문

Haskell/LYAH

[Learn You a Haskell For Great Good!] 3. 타입과 타입 클래스

jwvg0425 2015. 1. 18. 23:34


Learn You a Haskell For Great Good!


이 게시글은 http://learnyouahaskell.com/chapters 사이트에 올라와있는 글을 한글로 번역한 것입니다.의역이 굉장히 많으니 주의...


3. 타입과 타입 클래스


타입을 믿어라


 이전에 한 번 Haskell이 정적인 타입 시스템(static type system)을 갖고 있다고 언급한 적이 있었지. 모든 표현식의 타입은 컴파일 타임에 알 수 있고, 이건 더 안전한 코드를 만들어줘. 만약 프로그램을 짤 때 boolean 타입을 어떤 숫자로 나누려고 시도한다면, 그건 컴파일도 안될 거야. 이게 프로그램이 터졌을 때 에러를 발견하는 대신 컴파일 시간에 더 많은 에러를 잡을 수 있게 해주기 때문에 더 좋아. Haskell에 있는 모든 것들은 타입을 갖고 있고, 따라서 컴파일러는 컴파일하기 전에 네 프로그램에 대해 더 많은 것을 추론해낼 수 있어.

 Java나 Pascal과는 다르게, Haskell은 타입 추론(type inference) 기능을 갖고 있어. 숫자를 썼을 때 따로 Haskell에게 그게 숫자라고 알려줄 필요가 없지. Haskell은 스스로 그 타입을 추론해내고, 따라서 우리는 모든 함수와 표현식에 대해 일일히 명시적으로 타입을 써줄 필요가 없는거야. 타입에 대해 정말 대충 슥 훑어보는 것만으로도 Haskell의 기초중 일부분을 다룰 수 있어. 하지만, 타입 시스템을 이해하는 건 Haskell을 배우는 것에 있어 정말 중요한 부분이야.

 타입은 모든 표현식이 갖고 있는 라벨의 일종이야. 타입은 각 표현식이 어울리는 카테고리가 어떤건지 말해줘. 표현식 True는 Boolean이고, "hello"는 문자열이고, 뭐 그런 식.

 이제 몇몇 표현식들의 타입을 검사하기 위해 GHCI를 사용할 거야. 유효한 표현식 앞에 :t 커맨드를 사용하면 해당 표현식의 타입이 뭔지 알 수 있어. 봐봐.

  1. ghci> :t 'a'  
  2. 'a' :: Char  
  3. ghci> :t True  
  4. True :: Bool  
  5. ghci> :t "HELLO!"  
  6. "HELLO!" :: [Char]  
  7. ghci> :t (True'a')  
  8. (True'a') :: (BoolChar)  
  9. ghci> :t 4 == 5  
  10. 4 == 5 :: Bool  

 여기서 표현식에 :t 커맨드를 사용했을 때 표현식과 ::, 그 뒤에 해당 타입이 뭔지 나오는 걸 볼 수 있어. ::는 "has type of"라고 읽어. 명시적인 타입은 항상 첫번째 글자가 대문자로 표기돼. 'a'는 위에도 나와있듯이 Char 타입이지. 이게 문자를 나타내기 위한 타입이라는 결론을 내리긴 어렵지 않을거야. True는 Bool 타입이지. 이건 타당해. 하지만 이건 어때? "HELLO!"의 타입은 [Char]이라고 나와. 대괄호는 리스트를 나타내지. 따라서 우리는 이걸 문자의 리스트(문자열)로 읽을 수 있어. 리스트와는 다르게, 튜플은 길이마다 서로 다른 타입을 갖고 있어. 따라서 (True, 'a')는 (Bool, Char)타입을 갖는 반면에 ('a','b','c')와 같은 표현식은 (Char, Char, Char)타입을 갖겠지. 4==5 는 항상 False를 리턴하기 때문에 Bool 타입이야.

 함수도 마찬가지로 타입을 갖고 있어. 함수를 만들 때, 함수의 타입 선언을 명시함으로써 함수의 타입을 선택할 수 있어. 이건 보통 아주 짧은 함수를 쓸 때 말고는 좋은 연습이 돼. 이제부터, 우리는 우리가 만들 모든 함수에 대해 타입 선언을 명시할거야. 이전에 우리가 만든, 문자열에서 대문자인 문자만 남기는 조건 제시형 리스트를 기억해? 여기 그 타입 선언이 어떻게 되는 지 나와있어.

  1. removeNonUppercase :: [Char] -> [Char]  
  2. removeNonUppercase st = [ c | c <- st, c `elem` ['A'..'Z']]   

 removeNonUppercase는 [Char]->[Char] 타입을 가져. 이건 문자열을 문자열에 대응시킨다는 뜻이야. 왜냐하면 이 함수는 하나의 문자열을 인자로 받아서 다른 문자열 하나를 그 결과로 돌려주거든. [Char]타입은 String과 동의어니까, removeNonUppercase :: String -> String라고 쓰는 게 더 명확할거야. 이 함수에 따로 타입 선언을 해 줄 필요는 없어. 왜냐하면 컴파일러가 스스로 이 함수의 타입을 알아서 추론해낼 수 있거든. 아무튼 우린 타입을 명시했어. 여러 개의 인자를 받는 함수의 타입은 어떻게 써야할까? 여기 세 개의 정수를 받아서 그걸 더해 돌려주는 간단한 함수가 있어.

  1. addThree :: Int -> Int -> Int -> Int  
  2. addThree x y z = x + y + z  

 인자들은 ->로 구분되고 인자와 그 리턴 타입간에 특별한 구분은 없어. 리턴 타입은 타입 선언에서 맨 마지막 요소고 인자는 처음 세 개의 요소야. 나중에 왜 얘네들이 리턴 타입과 인자 사이에 Int, Int, Int -> Int같은 어떤 구분 없이 ->로만 구분해서 쓰는지 알아볼거야.

 만약 함수에 어떤 타입 선언을 해주고 싶지만 그게 어떤 타입인지 확신이 안간다면, 그냥 선언 없이 함수를 쓴 다음에 :t 명령어로 그 타입을 확인하면 돼. 함수도 표현식이고, 따라서 :t 명령어는 아무 문제없이 잘 동작해.

 몇가지 일반적인 타입에 대해 간략하게 알아보자.

 Int 는 정수를 나타내는 타입이야. 이건 모든 숫자들을 표현하는데 사용돼. 7은 Int지만 7.2는 아냐. Int는 한계가 있어(bounded). 무슨 뜻이냐면 이건 최솟값과 최댓값이 있다는 거야. 보통 32비트 환경에서 Int의 최댓값은 2147483647이고 최솟값은 -2147483648이야.

 Integer 는... 역시 정수를 나타내는 타입이야. 가장 큰 차이는 얘는 한계가 없어서 보통 아주아주 큰 숫자를 표현하는데 사용된다는 거야. 진짜 진짜 정말로 큰 숫자 말이야. 하지만 Int가 좀 더 효율적이긴 해.

  1. factorial :: Integer -> Integer  
  2. factorial n = product [1..n]  


  1. ghci> factorial 50  
  2. 30414093201713378043612608166064768844377641568960512000000000000  

Float은 보통 정밀도(single precision)의 부동 소수점 숫자야.

  1. circumference :: Float -> Float  
  2. circumference r = 2 * pi * r  


  1. ghci> circumference 4.0  
  2. 25.132742  

Double은 훨씬 정밀한(double the precision) 부동 소수점 숫자지!

  1. circumference' :: Double -> Double  
  2. circumference' r = 2 * pi * r  


  1. ghci> circumference' 4.0  
  2. 25.132741228718345  

Bool 은 논리 타입이야. 이건 True와 Fasle라는 두 가지의 값만 가져.

Char는 문자를 나타내. 이건 홑따옴표로 표기돼. 문자의 리스트는 문자열(String)이야.

 튜플도 타입이지만 이건 그들의 길이만큼이나 그 구성요소의 타입에 의존적이기 때문에, 이론적으로는 튜플의 타입은 무한히 많이 존재할 수 있고, 이 튜토리얼에서 그 모든 걸 다루기엔 숫자가 너무 많아. 한 가지 알아둘건 비어있는 튜플 () 또한 타입이고 이건 ()라는 한 가지 값밖에 가질 수 없어.


 타입 변수(Type variables)


 head 함수의 타입이 뭐라고 생각해? head는 임의 타입의 리스트를 받아서 그 첫번째 원소를 돌려주니까, 어떤 타입이 될까? 확인해봐!

  1. ghci> :t head  
  2. head :: [a] -> a  

 흐으음! a는 뭐지? 이게 타입이야? 이전에 타입은 대문자로 시작한다고 이야기했던 걸 떠올려봐. 이건 대문자로 시작하지 않기 때문에, 실제로는 타입 변수(type variable)야. a는 무슨 타입이든 될 수 있다는 걸 의미하지. 이건 다른 언어의 제네릭(generic)과 많이 비슷해. Haskell에서는 이게 훨씬 강력하게 동작해. 왜냐하면 어떤 함수가 타입에 관해 명확한 어떤 동작을 수행하지 않는다면 우리가 훨씬 쉽게 일반적인 함수들을 작성할 수 있게 만들어주기 때문이야. 타입 변수를 가진 함수는 다형적 함수(polymorphic functions)라고 불려. head의 타입 선언은 head가 임의 타입의 리스트를 인자로 받아서 해당 타입의 원소 하나를 돌려준다고 말하고 있는거야.

 타입 변수 이름이 한 글자 이상이어도 별 상관없지만, 보통 a,b,c,d... 라는 이름을 붙여.

 fst 함수 기억나? 이건 pair의 첫번째 원소를 돌려줘. 얘의 타입이 뭔지 알아보자.

  1. ghci> :t fst  
  2. fst :: (a, b) -> a  

 fst는 두 개의 타입을 가진 튜플을 받아서 페어의 첫번째 요소와 같은 타입의 원소를 돌려준다는 걸 알 수 있어. 그래서 fst를 어떤 임의 타입을 가진 페어에 대해서도 쓸 수 있지. a,b가 서로 다른 타입 변수라고 해서 반드시 그 둘이 서로 다른 타입이어야 할 필요는 없다는 걸 명심해둬. 이건 단지 첫번째 요소의 타입과 그 리턴 원소의 타입이 같다는 걸 말해주고 있을 뿐이야.


 타입 클래스 101(Typeclasses 101)


 타입 클래스는 어떤 행동을 정의해놓은 인터페이스(interface)의 일종이야. 어떤 타입이 타입 클래스에 속한다면, 해당 타입은 타입 클래스가 서술하는 행동을 지원하고 수행한다는 의미야. 객체지향 언어를 하다 온 사람들이 타입 클래스를 이해하는 것에서 혼란을 많이 겪는데, 왜냐하면 타입클래스가 객체지향 언어에서 클래스와 비슷한 거라고 생각하기 때문이야. 흠, 근데 그렇지 않아. 넌 이걸 Java의 인터페이스처럼 생각하는게 더 나을거야.

 == 함수의 타입 서명(type signature)은 뭘까?

  1. ghci> :t (==)  
  2. (==) :: (Eq a) => a -> a -> Bool  


주: 동등성 연산, == 는 함수야. 따라서 +, *, -, / 그리고 거의 모든 연산들도 함수지. 만약 함수가 특수 문자들로만 구성되어있다면, 이건 기본적으로 중위 함수(infix function)로 여긴다는 거야. 우리가 얘네들의 타입을 알기 원하거나, 다른 함수에 넘기거나 전위 함수(prefix function)로 호출하고 싶다면, 이걸 소괄호로 둘러싸야만 해.


 흥미롭지. 여기서 새로운 기호 =>를 발견할 수 있어. => 기호 전에 있는 건 전부 다 클래스 제약(class constraint)이라고 불러. 우린 위의 타입 선언을 이렇게 읽을 수 있어. '동등성 함수는 어떤 두 개의 값을 취하는데, 그 둘은 서로 같은 타입이어야하고 Bool을 리턴해. 이 두 값의 타입은 반드시 Eq 클래스의 멤버여야해(이 부분이 클래스 제약이야).'

 Eq 타입클래스는 동등성 비교를 위한 인터페이스를 제공해. 두 값이 서로 같은지 비교할 수 있는 타입이면 당연히 Eq 클래스의 멤버여야 해. Haskell의 모든 기본 타입은 IO(입출력을 다루기 위한 타입)와 함수를 제외하곤 Eq 타입 클래스에 속하는 타입들이야.

 elem 함수는 (Eq a) => a -> [a] -> Bool 타입을 가져. 왜냐하면 이 함수는 리스트를 돌면서 해당 값이 리스트 안에 속해있는지 아닌지를 확인하기 위해 == 함수를 이용하거든.

 몇가지 기본적인 타입클래스에 대해 알아보자.

 Eq 는 동등성 비교를 지원하는 타입을 위해 사용돼. 이 클래스의 멤버는 == 함수와 /= 함수를 수행할 수 있어. 따라서 함수의 타입 변수에 Eq 클래스 제약이 있다면, 이건 ==나 /= 함수를 그 함수의 정의 내부 어딘가에서 사용한다는 거야. 우리가 이전에 언급한 타입들은 함수를 제외하곤 전부 Eq 클래스의 멤버야. 따라서 우린 걔네들의 동등성을 비교할 수 있지.

  1. ghci> 5 == 5  
  2. True  
  3. ghci> 5 /= 5  
  4. False  
  5. ghci> 'a' == 'a'  
  6. True  
  7. ghci> "Ho Ho" == "Ho Ho"  
  8. True  
  9. ghci> 3.432 == 3.432  
  10. True  

 Ord는 순서를 가진 타입을 위한 거야.

  1. ghci> :t (>)  
  2. (>) :: (Ord a) => a -> a -> Bool  

  지금까지 다룬 타입들은 함수를 제외하곤 전부 Ord 클래스의 멤버야. Ord는 >, <, >=, 그리고 <=와 같은 모든 표준 비교함수들을 다루지. compare 함수는 서로 같은 타입인 두 Ord 클래스의 멤버를 받아서 그 순서를 돌려줘. Ordering은 GT, LT, 또는 EQ 값을 가질 수 있는 타입이야. 각각은 Greater than, lesser than과 equal을 의미하지.

 Ord의 멤버가 되려면, 해당 타입은 반드시 Eq 타입 클래스의 멤버여야해.

  1. ghci> "Abrakadabra" < "Zebra"  
  2. True  
  3. ghci> "Abrakadabra" `compare` "Zebra"  
  4. LT  
  5. ghci> 5 >= 2  
  6. True  
  7. ghci> 5 `compare` 3  
  8. GT  

 Show 클래스의 멤버는 문자열로 나타낼 수 있어. 지금까지 다룬 타입들은 함수를 제외하곤 전부 Show 클래스의 멤버야. Show 타입클래스가 처리하는 가장 많이 쓰이는 함수는 show야. 이 함수는 Show 클래스의 멤버 타입인 값을 하나 받아서 그걸 문자열로 돌려줘.

  1. ghci> show 3  
  2. "3"  
  3. ghci> show 5.334  
  4. "5.334"  
  5. ghci> show True  
  6. "True"  

 Read 는 Show 타입클래스와 반대되는 클래스의 일종이야. read 함수는 문자열을 받아서 Read 클래스의 멤버인 타입을 돌려줘.

  1. ghci> read "True" || False  
  2. True  
  3. ghci> read "8.2" + 3.8  
  4. 12.0  
  5. ghci> read "5" - 2  
  6. 3  
  7. ghci> read "[1,2,3,4]" ++ [3]  
  8. [1,2,3,4,3]  

 지금까진 아주 좋아. 다시 한 번 말하지만, 지금까지 다룬 모든 타입들은 이 타입클래스에 속해. 하지만 우리가 그냥 read "4" 라고만 적으면 어떤 일이 일어날까?

  1. ghci> read "4"  
  2. <interactive>:1:0:  
  3.     Ambiguous type variable `a' in the constraint:  
  4.       `Read a' arising from a use of `read' at <interactive>:1:0-7  
  5.     Probable fix: add a type signature that fixes these type variable(s)  

 여기서 GHCI가 말하는 건 우리가 뭘 리턴하길 원하는 지 알 수 없다는 거야. 위에서 우리는 read 함수를 쓰고 이후에 그 결과를 가지고 뭔가 처리를 했지. 이럴 때, GHCI는 우리가 read 함수의 결과로 어떤 타입을 원하는지 추측해내. 만약 우리가 read 함수의 결과를 boolean 타입으로 취급해서 처리했다면, GHCI는 우리가 read 함수의 결과로 Bool을 원한다는 걸 알 수 있어. 하지만 이 경우에는, 우리가 Read 클래스의 멤버인 어떤 타입을 원한다는 건 알지만 그게 어떤 건지 알 수가 없어. read 함수의 타입 서명을 한 번 살펴보자.

  1. ghci> :t read  
  2. read :: (Read a) => String -> a  

 알겠어? 이건 Read의 멤버인 타입을 리턴해주지만, 우리가 이걸 리턴받아서 어디선가 쓰지 않는다면 그 타입이 뭔지는 알 수가 없어. 따라서 우린 명시적인 타입 주석(type annotations)을 사용해야해. 타입 주석은 표현식의 타입이 어떠해야만 한다고 명시해주는 방법이야. 간단히 표현식의 뒤에 ::과 명시할 타입을 적어주면 돼. 봐봐.

  1. ghci> read "5" :: Int  
  2. 5  
  3. ghci> read "5" :: Float  
  4. 5.0  
  5. ghci> (read "5" :: Float) * 4  
  6. 20.0  
  7. ghci> read "[1,2,3,4]" :: [Int]  
  8. [1,2,3,4]  
  9. ghci> read "(3, 'a')" :: (IntChar)  
  10. (3'a')  

 대부분의 표현식은 컴파일러 스스로 그 타입을 추론해낼 수 있어. 하지만 가끔씩 컴파일러가 read "5"같은 표현식에서 Int 타입을 리턴해야할지 Float 타입을 리턴해야할지 알 수 없을 때가 있지. 그게 어떤 타입인지 알기 위해 Haskell은 실제로 read "5"를 평가해야만 해. 하지만 haskell은 정적인 타입의 언어이기 때문에, 코드가 컴파일되기 전에(혹은 GHCI의 경우는 식이 평가되기 전에) 모든 타입을 알아야돼. 그래서 Haskell한테 "얌마, 이 표현식은 이 타입이어야만 돼.혹시 니가 모를 수도 있으니까 알려주는거야!"라고 말해줘야 하는거야.

 Enum 클래스의 멤버는 연속적인 순서를 갖는 타입들이야. 이것들은 열거될 수 있지. Enum 타입 클래스의 가장 큰 이점은 우리가 그 타입을 리스트 범위(list range)에서 사용할 수 있다는 거야. 또 이 클래스의 멤버들은 successors와 predecessors를 정의하고 있어. succ 함수와 pred함수로 각각을 얻을 수 있지. 이 클래스에 속한 타입은 다음과 같아. (), Bool, Char, Ordering, Int, Integer, Float 그리고 Double.

  1. ghci> ['a'..'e']  
  2. "abcde"  
  3. ghci> [LT .. GT]  
  4. [LT,EQ,GT]  
  5. ghci> [3 .. 5]  
  6. [3,4,5]  
  7. ghci> succ 'B'  
  8. 'C'  

 Bounded 클래스의 멤버는 상한선과 하한선을 갖고 있어.

  1. ghci> minBound :: Int  
  2. -2147483648  
  3. ghci> maxBound :: Char  
  4. '\1114111'  
  5. ghci> maxBound :: Bool  
  6. True  
  7. ghci> minBound :: Bool  
  8. False  

 minBound 함수와 maxBound 함수는 (Bounded a) => a 라는 타입을 갖고 있어서 흥미로워. 얘네들은 어느정도 다형적인 제약(polymorphic constraints)을 갖고 있지.

 모든 튜플들은 그 구성요소들이 Bounded의 멤버라면 자신도 역시 Bounded의 멤버가 돼.

  1. ghci> maxBound :: (BoolIntChar)  
  2. (True,2147483647,'\1114111')  

 Num은 숫자 타입클래스야. 이 클래스의 멤버는 숫자처럼 동작할 수 있는 특징을 갖고 있어. 숫자의 타입을 한 번 조사해보자.

  1. ghci> :t 20  
  2. 20 :: (Num t) => t  

 모든 숫자들은 역시 다형적인 제약처럼 나타나. 얘네들은 Num의 멤버인 타입이라면 어떤 타입처럼도 행동할 수 있어.

  1. ghci> 20 :: Int  
  2. 20  
  3. ghci> 20 :: Integer  
  4. 20  
  5. ghci> 20 :: Float  
  6. 20.0  
  7. ghci> 20 :: Double  
  8. 20.0  

 이것들이 Num 타입클래스의 멤버들이야. * 함수의 타입을 조사해보면, 이 함수는 모든 숫자들에 대해 동작한다는 걸 볼 수 있어.

  1. ghci> :t (*)  
  2. (*) :: (Num a) => a -> a -> a  

 이 함수는 같은 타입의 숫자 두 개를 받아서 해당 타입의 숫자를 돌려줘. 그래서 (5 :: Int) * (6 :: Integer)는 타입 에러가 발생하지만, 반면에 5 * (6 :: Integer)는 잘 동작하고 그 결과로 Integer 타입을 돌려줘. 5는 Integer로도 Int로도 동작할 수 있거든.

 Num의 멤버가 되기 위해선 해당 타입은 먼저 Show와 Eq의 멤버여야해.

 Integral 타입 클래스 역시 숫자 타입 클래스야. Num은 정수와 실수를 포함한 모든 숫자들을 포함하지. Integral은 모든 정수들만을 포함해. 이 타입클래스에 속한 타입에는 Int와 Integer가 있어.

 Floating은 부동소수점수들만 포함해. 그래서 Float과 Double만 이 타입클래스에 속하지.

 숫자를 다루는 아주 유용한 함수중에 fromIntegral 이 있어. 이건 fromIntegral :: (Num b, Integral a) => a -> b 라는 타입 서명을 가지고 있지. 이 타입 서명으로부터 우리는 이게 정수를 받아서 그걸 좀 더 일반적인 숫자로 바꿔주는 역할을 한다는 걸 알 수 있어. 이건 정수와 부동 소수점 타입을 서로 같이 잘 동작하게 만들고 싶을 때 유용해. 한 예로, length 함수는 좀 더 일반적인 타입인 (Num b)=> length :: [a] ->b가 아니라 length :: [a] -> Int 라는 타입서명을 갖고 있어. 내 생각에 이건 뭔가 역사적 이유나 어떤 다른게 있었던 것 같애. 내 의견이긴 하지만 이렇게 만들어 놓은 건 정말 바보같은 짓이야. 어쨌든, 우리가 list의 길이를 구해서 그걸 3.2에 더하려고 한다면, Int와 부동 소수점 수를 더하려고 한 것이기 때문에 에러를 발생시켜. 그래서 이걸 해결하기 위해, fromIntegral (length [1,2,3,4]) + 3.2라고 쓰면 잘 동작해.

 fromIntegral은 그 타입 서명에 여러 개의 클래스 제약을 갖는다는 걸 기억해둬. 위에서 봤듯이 이건 완전히 정당한 표현이고, 클래스 제약은 소괄호 안에서 콤마(,)를 이용해 구분돼.