User Tools

Site Tools


study:haskell:ch2

타입과 함수

왜 타입인가?

헤스켈의 모든 표현식과 함수는 타입을 갖는다

  • True는 Bool 타입을 갖는다.
  • “foo”는 String 타입을 갖는다.

값(Value)의 경우에 있어, 값(Value)의 타입이라고 하는 것은 그 값(Value)들의 어떤 공통된 속성을 말한다. 예를 들어

  • 숫자들은 더(add)할 수 있고
  • 리스트들은 합쳐(concatenate)질 수 있다.

이런 것들이 그 타입들의 속성이다. (즉 더하고 합치는 것)

우리는 표현식에 대해 다음과 같이 말한다.

  • “표현식이 X 타입을 갖는다”
  • “표현식은 X 타입이다”

왜 타입인가?

최저 수준에서 컴퓨터는 바이트만을 처리하지만, 타입 시스템은 추상성을 제공한다.

타입은 바이트에 의미(meaning)을 더한다.

  • “이 바이트들은 텍스트이다”
  • “이 바이트들은 비행기 예약이다”

타입 시스템은 이러한 타입을 잘못해서 섞이는 것을 방지해 준다. 예를 들어, 호텔 예약과 자동차 렌탈 영수증.

추상성이 주는 이점은 저수준의 자세한 사항에 대해 몰라도 된다.

  • 어떤 값이 스트링이라면 그것이 어떻게 구현되었는지 몰라도 된다.

타입 시스템은 다양하다.

그들은 각자의 문제 영역에 관심을 갖는다.

한 언어의 타입 시스템은 그 언어로 생각하고 코딩하는 방식에 매우 영향을 준다.

헤스켈 타입 시스템

헤스켈 타입 시스템의 특징 :

  • 강타입(strong)이다.
  • 정적 타입(static)이다.
  • 타입 추론(inferred)이다.

강타입(Strong Types)

강타입이라는 것의 의미:

  • 프로그램이 특정 종류의 에러를 갖지 않도록 타입 시스템이 보장한다는 것을 말한다.
    • 정수를 함수로 사용한다거나, 정수 파라미터를 원하는 함수에 문자열을 보내는 것.
    • well typed : 언어의 타입 규칙을 준수하는 표현식.
    • ill typed : 언어의 타입 규칙을 준수하지 않는 표현식. 타입 에러 발생.
  • 자동 타입 변환을 하지 않는다.
    • 암시적 타입 변환을 제공하지 않는다.
    • 타입 변환을 위해서는 명시적 타입 변환 함수를 사용해야 한다.

강타입이 정말 좋은 점은 문제가 생기기 전에 버그를 잡을 수 있다는 것이다.

(예를 들어, 정수를 원하는 곳에 문자열을 잘못 사용하는 일은 일어날 수 없다)

강타입과 약타입 용어 문제

보통 일상 영어에서 사람들은 "약한(weak)" 것 보다 "강한(strong)" 것을 더 좋은 것이라고 가치판단한다.
하지만 학문적 영역에서 "강한(strong)" 과 "약한"은 그저 더 엄격하거나 더 허용적인 것을 말할 뿐이다.
예를 들어 펄 언어에서는 "foo" + 2 = 2, "13foo" + 2 = 15가 되지만, 헤스켈에서는 않된다.
대부분 프로그래머들은 학문적 의미보다는 일상적 의미로 말하기 때문에, 또 학문하는 사람들은 자신의 생각과는 다른 
타입 시스템에는 맹공격을 퍼붇기 때문에, 종종 인터넷에서는 소모적인 날선 공방이 벌어지곤 한다.

정적 타입(Static Types)

정적 타입이라고 함은 컴파일러가 모든 값과 표현식의 타입을 컴파일할 때 안다라는 것이다.

ghci> True && "false"
<interactive>:1:8:
    Couldn't match expected type `Bool' against inferred type `[Char]' 
    In the second argument of `(&&)', namely `"false"'
    In the expression: True && "false"
    In the definition of `it': it = True && "false"

파이썬의 오리타입(duck typing)과 비교하자면. 정적 타입은 코딩을 좀 어렵게 한다. 다행이 헤스켈에는 타입클래스(typeclasses)라는 것이 있어서 (6장에서 설명한다) 동적 타입의 장점을 제공해 주고 있다. 동적 타입이라는 개념을 완전하게 수용하는 언어만큼 쉬운 편은 아니지만, 헤스켈은 동적 타입 프로그래밍을 위해 몇가지 지원을 한다.

헤스켈의 강타입과 정적 타입의 결합은 런타임시에 타입 에러가 발생하는 것을 불가능하게 만든다. 이것은 우리가 생각을 더 많이 하고 프로그래밍해야 한다는 것 의미하지만, 단순하지만 정말 찾기 어려운 버그를 제거해 준다.

한 번 컴파일되면 그것은 다른 어떤 언어에서 보다 정확하게 동작한다는 것은 헤스켈 커뮤니티에서는 매우 당연한 일이다.

타입 추론(Type Inference)

헤스켈 컴파일러는 자동적으로 모든 표현식의 타입을 추론할 수 있다.

헤스켈은 명시적으로 타입을 선언할 것을 허용하지만 타입 추론덕분에 이것은 선택사항일 뿐, 반드시 해야하는 일은 아니다.

타입 시스템으로 기대할 수 있는 것

타입 시스템에 대한 설명은 이 책 전반에 걸쳐 진행된다.

  • 정적 강타입 - 코드의 안전성
  • 타입 추론 - 코드의 간결성

기본 공통 타입

  • Char
    • Unicode 문자
  • Bool
    • 불린 로직. True, False
  • Int
    • signed, fixed width integer. 32bit 머신에서는 32bits, 64bit 머신에서는 64bits.
    • 헤스켈 표준은 Int가 28bit보다 크다는 것만을 보장한다.
  • Integer
    • 무제한 크기 정수.
    • 성능과 공간 문제.
    • overflow 없음.
  • Double
    • 시스템의 기본 부동소수
    • 64bits
    • Float는 있으나 사용을 권장하지 않음.
      • Double이 Float보다 더 빠르고 효율적임.
      • 헤스켈 컴파일러 작성자는 Double에 더 집중했기 때문에.

헤스켈의 타입 표기법

 expression :: MyType

::과 MyType을 합쳐 타입 서명(Type Sinagture)라고 한다.

타입 서명을 생략하면 컴파일러가 타입을 추론한다.

ghci> :type 'a' 
'a' :: Char
ghci> 'a' :: Char 
'a'
ghci> [1,2,3] :: Int
<interactive>:1:0:
    Couldn't match expected type `Int' against inferred type `[a]' 
    In the expression: [1, 2, 3] :: Int
    In the definition of `it': it = [1, 2, 3] :: Int

함수의 적용

함수를 적용하기 위해서는 함수 이름을 먼저 적고 뒤에 파라미터를 적는다.

ghci> odd 3 
True
ghci> odd 6 
False

함수에 파라미터를 적용하는데 있어 괄호와 컴마를 사용하지 않는다.

ghci> compare 2 3 
LT
ghci> compare 3 3 
EQ
ghci> compare 3 2 
GT

함수 호출은 가장 높은 우선성을 갖기 때문에 다음 두 표현식은 같다.

ghci> (compare 2 3) == LT 
True
ghci> compare 2 3 == LT 
True

위의 경우 괄호를 생략하는 것이 미관상 좋지만, 다음과 같은 경우에는 반드시 사용해야 한다.

ghci> compare (sqrt 3) (sqrt 6) 
LT

위의 경우 괄호를 사용하지 않으면 compare 함수가 4개의 파라미터를 받는 것이 된다.

유용한 구성 타입

리스트와 튜플은 헤스켈에서 가장 기본이 되는 가장 공통적인 구성 타입니다.

head 함수는 리스트의 첫 요소를 리턴한다.

ghci> head [1,2,3,4]
1
ghci> head ['a','b','c'] 
'a'

tail은 리스트에서 첫 요소를 뺀 나머지이다.

ghci> tail [1,2,3,4] 
[2,3,4]
ghci> tail [2,3,4] 
[3,4]
ghci> tail [True,False] 
[False]
ghci> tail 
"list"
"ist"
ghci> tail []
*** Exception: Prelude.tail: empty list

head는 리스트의 요소의 타입에는 관계없이 동작한다.

리스트는 어느 타입이든 올 수 있기 때문에 이를 타입 다형적(type polymorphic) 이라 한다.

다형성 타입을 쓸 때는 타입 변수(type varialbe)를 사용하는데, 반드시 소문자로 시작해야 한다.

타입 변수는 자리표시자이다. 나중에 실제 타입으로 바꾸게 된다.

a의 리스트 (a는 타입 변수) :

 [a]

타입 변수를 []으로 둘러싸서 a의 리스트를 [a]로 쓸 수 있다.

따라서 타입 이름은 반드시 대문자로 시작해야 한다.

특정 타입의 값으로 리스트에 대해 말할 때, 타입 변수를 그 특정 타입으로 바꾼다.

즉 [Int]는 Int 타입 값의 리스트이다. 여기서 a는 Int로 교체되었다.

ghci> :type [[True],[False,False]] 
[[True],[False,False]] :: [[Bool]]

튜플

튜플은 고정 크기 콜렉션이다. 각 요소들은 서로 다른 타입일 수 있다.

반면 리스트는 가변 크기 콜렉션이지만, 그 요소들은 같은 타입이어야 한다.

ghci> (1964, "Labyrinths") 
(1964,"Labyrinths")
ghci> :type (True, "hello") 
(True, "hello") :: (Bool, [Char]) 
ghci> (4, ['a', 'm'], (16, True)) 
(4,"am",(16,True))

특수 타입 : 요소없는 튜플.

 ()

이 타입은 오직 하나의 값만 갖는데, 그 값 또한 ()이다. 이 타입과 값 모두 “unit”이라고 불린다. C의 void와 좀 비슷하다.

헤스켈에서는 하나의 요소를 갖는 튜플은 있을 수 없다. 튜플은 항상 그 요소가 2개 이상이다.

  • 2-튜플(2-tuple) : 쌍(pair)
  • 3-튜플(3-tuple) : (triple)
  • 5-튜플(5-tuple) :

튜플의 타입은 그 요소들의 개수, 위치, 타입에 의해 정해진다. 이것이 다르면 다른 타입의 튜플이다.

ghci> :type (False, 'a') 
(False, 'a') :: (Bool, Char) 
ghci> :type ('a', False) 
('a', False) :: (Char, Bool)
ghci> :type (False, 'a', 'b')
(False, 'a', 'b') :: (Bool, Char, Char)

튜플은 함수에서 여러 개의 값을 리턴할 때 사용된다.

리스트와 튜플에 적용되는 함수

take와 drop 함수 : 숫자 n과 리스트를 파라미터로 받는다.

ghci> take 2 [1,2,3,4,5] 
[1,2]
ghci> drop 3 [1,2,3,4,5] 
[4,5]

fst와 snd 함수 : 2-튜플(pair)를 파라미터로 받는다.

ghci> fst (1,'a') 
1
ghci> snd (1,'a') 
'a'

위 표현식은 다른 언어에서라면 마치 2개의 파라미터를 받는 함수의 호출같아 보인다. 헤스켈에서는 하나의 튜플을 파라미터로 받는 함수 호출이다.

함수에 표현식 전달하기

헤스켈에서 함수의 호출은 왼쪽 결합이다. 즉 표현식 a b c d는 (((a b) c) d)와 같다. 따라서 함수에 표현식을 전달하기 위해서는 괄호를 사용해야 한다.

ghci> head (drop 4 "azerty") 
't'

함수 타입과 순수성

함수의 타입을 보자.

ghci> lines "the quick\nbrown fox\njumps" 
["the quick","brown fox","jumps"]
ghci> :type lines
lines :: String -> [String]

→는 return을 의미한다. 즉 함수 타입이다.

“:: String → [String]“는 “String을 입력받아 [String]을 리턴하는 함수 타입”으로 읽을 수 있다.

헤스켈의 함수는 기본적로 순수함수이다. 즉 부수효과(side effects)가 없다.

부수효과가 있는 함수의 서명은 그 리턴이 “IO”로 시작한다.

ghci> :type readFile
readFile :: FilePath -> IO String

헤스켈의 타입 시스템이 여기서 사용되면 순수 함수와 비순수 함수가 잘못해서 섞이는 것을 방지한다.

헤스켈 소스 파일, 함수 작성하기

함수 작성을 하기에 ghci는 제한적이다. (ghci가 운영되는 환경은 IO Monad라고 한다.)

함수 작성을 위해서는 소스파일을 만들어야 한다. 헤스켈 소스 파일의 확장자는 .hs이다.

-- file: ch03/add.hs 
add a b = a + b

= 의 왼쪽으로 함수 이름과 파라미터가 오고, 오른쪽으로는 함수의 몸체가 온다.

ghci에서 add.hs 소스 파일을 로드하면, 함수를 바로 사용할 수 있다.

ghci> :load add.hs
[1 of 1] Compiling Main ( add.hs, interpreted ) 
Ok, modules loaded: Main.
ghci> add 1 2
3

헤스켈의 함수는 return 키워드가 없는데, 그것은 함수가 단일 표현식인데, 이 표현식의 값이 함수의 리턴값이기 때문이다. (헤스켈에는 'return'함수가 있기는 하지만, 전혀 의미가 다르다)

헤스켈 코드에서 = 심볼은, 그것은 왼쪽이 오른쪽의 표현식으로 정의된다라는 의미한다.

그럼 변수는 무엇인가?

헤스켈에서 변수는 표현식에 이름을 주는 방식일 뿐이다. 변수가 어떤 표현식에 묶이면(bound) 그 값은 변하지 않는다.

-- file: ch02/Assign.hs 
x = 10
x = 11
ghci> :load Assign
[1 of 1] Compiling Main
( Assign.hs, interpreted )
Assign.hs:4:0:
Multiple declarations of `Main.x' Declared at: Assign.hs:3:0
Assign.hs:4:0 Failed, modules loaded: none.

한 변수에 값을 2번 지정하면 에러가 발생한다.

조건 표현식

ghci> drop 2 "foobar" 
"obar"
ghci> drop 4 "foobar" 
"ar"
ghci> drop 4 [1,2] 
[]
ghci> drop 0 [1,2] 
[1,2]
ghci> drop 7 []
[]
ghci> drop (-2) "foo" 
"foo"

drop 함수는 n이 0 이하면, 원본 리스트를 그대로 리턴하고, 그렇지 않으면 n 개만큼을 빼고 리턴한다.

-- file: ch02/myDrop.hs
myDrop n xs = if n <= 0 || null xs
              then xs
              else myDrop (n - 1) (tail xs)

헤스켈에서 들여쓰기는 중요하다.

이것은 = 다음의 표현식 정의를 다음 문자에서 계속한다는 것을 의미한다. 이 함수는 if절이 맨 앞에 있기 때문에 True나 False를 리턴하는 predicate 함수이다.

then과 else 절에는 반드시 같은 타입의 표현식이 와야한다.

헤스켈은 표현식 지향 언어이다.

그래서 else가 없는 if 표현식은 그 값이 False일 때 리턴하는 값이나 타입이 없기 때문에 무의미한 표현식이 된다. (앞의 파일에서 else 를 지우고 ghci에서 다시 로딩하면 모듈 로드가 실패한다)

null 프리디킷과 || 연산



ghci> :type null
null :: [a] -> Bool
ghci> :type (||)
(||) :: Bool -> Bool -> Bool

예제를 통한 평가(evaluation) 이해

Lazy evaluation

-- file: ch02/RoundToEven.hs 
isOdd n = mod n 2 == 1

isOdd (1 + 2) 은 어떻게 평가될까?

보통 (1 + 2)가 먼저 평가되어, isOdd 3으로 되고, 그 다음 mod 3 2가 평가되고, 1 == 1이 평가되어 True가 된다.

엄격한 평가를 하는 언어에서는 인수는 함수가 적용되기 전에 먼저 평가된다. 헤스켈은 엄격하지 않은 평가를 한다.

헤스켈에서 (1 + 2)는 바로 3으로 평가되지 않는다. 대신 표현식이 평가될 필요가 있을 때 그것을 계산할 것이라는 일종의 “약속”를 만든다.

이렇게 평가되지 않은 표현식을 계속 추적하기 위해 기록한 것을 thunk라고 한다. thunk를 만들어 실제 값이 필요할 때까지 계산을 지연한다.

표현식의 결과가 필요하지 않으면 결코 그 값을 계산하지 않는다.

엄격하지 않는 평가는 종종 lazy evaluation이라고 불린다.

더 상세한 예제

myDrop 2 “abcd” 를 계산해 보자.

ghci> print (myDrop 2 "abcd")
"cd"

계산되게 하기 위해 print를 사용한다.

myDrop 함수에 2와 “abcd”가 전달되어, n과 xs에 바인딩된다. myDrop 함수의 if 절은 다음과 같이 된다.

ghci> :type 2 <= 0 || null "abcd" 
2 <= 0 || null "abcd" :: Bool

이제 || 연산자의 왼쪽항이 평가된다.

ghci> 2 <= 0 
False

이 값이 || 연산자 표현식에 대체된다.

ghci> :type False || null "abcd"
False || null "abcd" :: Bool
ghci> null "abcd" 
False

이제 || 연산자 표현식은 다음과 같이 된다.

ghci> False || False 
False

if 절이 Flase이므로 else 브랜치가 평가된다. 이것은 재귀호출이다.

재귀 호출

myDrop은 (2 - 1)과 tail “abcd”로 다시 호출된다. 다음과 같다.

ghci> :type (2 - 1) <= 0 || null (tail "abcd") 
(2 - 1) <= 0 || null (tail "abcd") :: Bool
ghci> :type (2 - 1) <= 0 
(2 - 1) <= 0 :: Bool 
ghci> 2 - 1
1
ghci> 1 <= 0 
False

(2 - 1)은 실제 필요할 때에 평가되었다. 이제 || 연산자의 오늘쪽 항이 평가될 차례이다.

ghci> :type null (tail "abcd") 
null (tail "abcd") :: Bool 
ghci> tail "abcd"
"bcd"
ghci> null "bcd" 
False

if 절이 다시 False로 평가되어, else 브랜치가 평가된다. 여기서 n은 1이고, xs는 “bcd”이다.

재귀 종료

ghci> :type (1 - 1) <= 0 || null (tail "bcd")
(1 - 1) <= 0 || null (tail "bcd") :: Bool
ghci> :type (1 - 1) <= 0 
(1 - 1) <= 0 :: Bool 
ghci> 1 - 1
0
ghci> 0 <= 0 
True

왼쪽항이 True이기 때문에, || 연산자의 오른쪽 항은 평가되지 않는다.

ghci> True || null (tail "bcd")
True

if 절이 True이기 때문에, then 브랜치가 평가된다.

ghci> :type tail "bcd"
tail "bcd" :: [Char]

재귀에서 리턴

현재 우리는 myDrop의 두번째 재귀 호출에 있는데, 이것이 tail “bcd”로 평가되었다. 이전 평가식을 대체한다.

ghci> myDrop (1 - 1) (tail "bcd") == tail "bcd" 
True

첫번째 재귀 호출도 똑같이 대체된다.

ghci> myDrop (2 - 1) (tail "abcd") == tail "bcd" 
True

마지막으로 원래의 호출이 대체된다.

ghci> myDrop 2 "abcd" == tail "bcd" 
True

지금까지 오면서 tail “bcd”는 평가될 필요가 없었다.

원래 호출의 최종 결과는 thunk이다. 이것은 ghci에서 평가된다.

ghci> myDrop 2 "abcd" 
"cd"
ghci> tail 
"bcd"
"cd"

시사점

  • 헤스켈의 평가를 이해하기 위해 대체 재작성이 의미가 있다.
  • 지연 평가는 값이 필요할 때까지 평가를 지연하고, 값으로 되어야 할 표현식만을 평가한다.
  • 함수의 적용 결과는 thunk일 수 있다.(즉 지연된 평가)

다형성

리스트 타입은 다형성이라고 했다. last 함수의 경우 리스트의 요소의 타입에 상관없이 똑같이 행동한다.

ghci> last [1,2,3,4,5] 
5
ghci> last "baz"
'z'

사실 last 함수의 서명에는 타입 변수가 있다.

ghci> :type last
last :: [a] -> a

이 서명은 “요소의 타입이 a인 리스트를 입력받아 a 타입의 값을 리턴한다”라고 읽을 수 있다.

헤스켈의 다형성은 파라미터 다형성이다. 파라미터 다형성은 실체 타입에 무엇인지에 대한 정보를 갖는 못하기 때문에 값을 생성시키지 못한다.

  • 파라미터 다형성 : 타입 변수가 있는 다형성. C++ 템플릿이나 자바의 제너릭에서처럼. 헤스켈의 다형성.
  • 서브타입 다형성 : 서브 클래스에 따른 다형성. 객체지향언어. (따라서 헤스켈에는 없다)
  • 강제 다형성 : 정수와 부동소수점 수와 같이 강체 형 변환의 경우. (따라서 헤스켈에는 없다)

다형적 함수에 대한 추론

ghci> :type fst 
fst :: (a, b) -> a

여기서 타입 변수는 2개이다. fst의 결과 타입은 a이지만, fst는 a 타입 값을 생성에 대한 어떤 정보도 없기 때문에 값을 생성하지 못한다. fst가 할 수 있는 유일한 것은 튜플의 첫 요소를 리턴하는 것이다.

하나 이상의 인수를 갖는 함수의 타입

take 함수를 보자.

ghci> :type take
take :: Int -> [a] -> [a]

→ 이 2개나 있다?

헤스켈은 →을 오른에서 왼쪽으로 결합한다. 즉 →은 오른쪽 결합이다. 이렇게 해서 괄호로 묶어내면 더 명확하다.

-- file: ch02/Take.hs
take :: Int -> ([a] -> [a])

이 타입 서명은 “정수를 입력받아, 리스트를 입력받아 리스트를 리턴하는 함수를 리턴한다”이다. 이것에 대한 자세한 설명은 100p의 “부분함수 적용과 커링”에서 한다.

일단 현재는 맨 마지막의 → 다음에 있는 타입은 리턴 타입이고, 그 이전 것들은 함수의 인수라고 알면 된다.

myDrop의 경우 타입은 다음과 같이 쓸 수 있다.

-- file: ch02/myDrop.hs
myDrop :: Int -> [a] -> [a]
study/haskell/ch2.txt · Last modified: 2019/02/04 14:26 (external edit)