4장 함수의 구문

아래 주피터 노트북은 James Brock의 learn-you-a-haskell-notebook을 기본틀로 사용합니다.

참고: 린팅(linting) 기능 끄기

  • 린팅(linting): IHaskell에서 HLint라고 불리는 린터(linter)에 의해 보다 적절하다고 판단하는 표현식을 제안하는 기능
  • 보다 세련된 표현식(expression)을 제안하는 도구임. 하지만 반드시 필요한 기능은 아니다
  • 참조: IHaskell의 린팅 기능 설정하기
In [1]:
:opt no-lint

참고: 주피터랩 단축키

주피터랩에서 가장 유용한 단축키 조합은 다음과 같다.

단축키 조합 실행결과
Shift+Enter 선택한 셀을 실행하고 다음 셀로 이동
Ctrl+Enter 선택한 셀을 실행. 다음 셀로 이동하지 않음
Alt+Enter 선택한 셀을 실행하고 선택한 셀 뒤에 새로운 셀 삽입
Enter 선택한 셀 편집
Ctrl+Shift+- 커서 위치에서 셀 분할

모든 가능한 단축키 조합은 왼편 사이드바에 위치한 돋보기 아이콘을 눌러 확인하거나 검색할 수 있다. 주피터랩의 단축키 설정 및 주요 단축키에 대한 자세한 설명은 주피터랩 인터페이스를 참조할 수 있다.

4.1 패턴 매칭과 case 표현식

앞서 함수를 정의하는 기본적인 방식을 다뤘다. 여기서는 구문 분석(syntax analysis)과 가드(guard), 지역 함수(local function) 등의 기능을 이용하여 함수를 보다 효율적으로 정의하는 방법을 설명한다. 먼저 패턴 매칭에 대해 알아본다.

패턴은 유형의 값이 가질 수 있는 정형화된 양식을 의미하며, 그 양식에 따라 함수의 값을 다르게 지정하도록 하는 기능이 패턴 매칭(pattern matching)이다. 함수의 인자로 사용되는 값이 가질 수 있는 패턴에 따라 함수의 본문을 따로따로 정의하면 코드가 보다 깔끔해지고 가독성이 높아진다. 하스켈에서 패턴 매칭은 숫자, 문자, 리스트, 튜플 등 모든 자료형(data type)에 대해허용된다.

참고: 자료형(data type)과 유형(type)은 구분되어야 한다. 자료형은 모두 하스켈의 유형인 반면에, 예를 들어, 함수의 유형은 일반적으로 자료형이 아니다. 앞서 언급된 자료형 이외에 다른 자료형에 대해서는 나중에 자세히 다룬다.

예제

함수의 인자가 7인지 여부를 판단하여 다른 문자열을 반환하는 함수는 다음과 같다.

In [2]:
lucky :: (Integral a) => a -> String
lucky 7 = "LUCKY NUMBER SEVEN!"
lucky x = "Sorry, you're out of luck, pal!"

lucky 함수를 인자와 함께 호출하면 아래 과정이 차례대로 수행된다.

  • 사용된 인자에 대한 패턴 매칭은 함수의 정의에서 먼저 언급된 경우부터 순서대로, 즉, 위에서부터 아래로 각 패턴을 확인하면서 실행된다.
  • 지정된 인자의 패턴에 부합하지 않으면 다음 패턴으로 넘어가고, 부합되는 패턴을 찾으면 거기서 지정된 함수의 본문을 실행한다.

예를 들어, 인자가 7이면 첫 번째 패턴에 부합한다.

In [3]:
lucky 7
"LUCKY NUMBER SEVEN!"

만약 7이 아니면, 두 번째 패턴으로 넘어간다. 그리고 이 패턴은 어떤 숫자에 대해서도 매칭이 이루어지도록 설정되어 있다. 이와 같이 임의의 매개 변수 형식으로 지정된 패턴은 어떤 값에 대해서도 매칭되며, 그 값을 해당 변수가 가리키게 된다. 여기서는 물론 7이 아닌 모든 다른 수에 대해서 작동한다. 이유는 7은 이전 패턴에서 걸러지기 때문이다.

In [4]:
lucky 17
"Sorry, you're out of luck, pal!"
In [5]:
lucky 33
"Sorry, you're out of luck, pal!"

만약에 패턴 매칭 순서를 아래처럼 바꾸면 모든 숫자가 첫 번째 패턴 매칭에서 처리된다.

In [6]:
notLucky :: (Integral a) => a -> String
notLucky x = "Sorry, you're out of luck, pal!"
notLucky 7 = "notLucky NUMBER SEVEN!"

심지어 7도 첫 번째 패턴에 매칭되어 버린다.

In [7]:
notLucky 7
"Sorry, you're out of luck, pal!"

lucky 함수를 정의할 때 지정된 유형을 보면 Integral 유형 클래스가 클래스 제약으로 사용되었다. 따라서 Int 또는 Integer 등 정수형 값만 인자로 사용되며, 아래와 같이 7.0을 사용하면 오류가 발생한다.

lucky 7.0

부동소수점에 대해서도 작동하게 하려면 lucky 함수의 유형을 보다 일반화해야 한다. 그런데 어떻게 유형을 지정해야할지 모를 때는 아래와 같이 유형을 지정하지 않으면서 함수를 정의하면 된다.

In [8]:
lucky' 7 = "LUCKY NUMBER SEVEN!"
lucky' x = "Sorry, you're out of luck, pal!"

lucky' 함수의 유형은 하스켈이 자동으로 유추해낸다.

In [9]:
:t lucky'
lucky' :: forall a. (Eq a, Num a) => a -> [Char]

lucky' 함수는 동치성 검사가 가능한 Num 유형 클래스에 포함되는 유형의 값을 인자로 사용한다. 다행히도 부동소수점의 유형인 Float, Double 등이 모두 해당되어 아래 계산이 정상적으로 작동한다.

In [10]:
lucky' 7.0
"LUCKY NUMBER SEVEN!"
In [11]:
lucky' 7
"LUCKY NUMBER SEVEN!"
In [12]:
lucky' 33.1
"Sorry, you're out of luck, pal!"

패턴 매칭 vs. if 표현식

lucky 함수를 if 표현식 사용하여 정의할 수도 있다.

In [13]:
lucky :: Integral a => a -> String
lucky x = if x == 7 then "LUCKY NUMBER SEVEN!" 
                    else "Sorry, you're out of luck, pal!"
In [14]:
lucky 7
"LUCKY NUMBER SEVEN!"
In [15]:
lucky 13
"Sorry, you're out of luck, pal!"

경우의 수를 하나 늘려보자.

In [16]:
luckyTwice :: (Integral a) => a -> String
luckyTwice 7 = "Lucky NUMBER SEVEN!"
luckyTwice 77 = "Lucky twice NUMBER SEVENTY SEVEN!"
luckyTwice x = "Sorry, you're out of luck, pal!"
In [17]:
luckyTwice 7
"Lucky NUMBER SEVEN!"
In [18]:
luckyTwice 77
"Lucky twice NUMBER SEVENTY SEVEN!"
In [19]:
luckyTwice 13
"Sorry, you're out of luck, pal!"
In [20]:
luckyTwice x = if x == 7 then "LUCKY NUMBER SEVEN!" 
                         else if x == 77 then "Lucky twice NUMBER SEVENTY SEVEN!"
                                         else "Sorry, you're out of luck, pal!"
In [21]:
luckyTwice 7
"LUCKY NUMBER SEVEN!"
In [22]:
luckyTwice 77
"Lucky twice NUMBER SEVENTY SEVEN!"
In [23]:
luckyTwice 13
"Sorry, you're out of luck, pal!"

작동하긴 한다. 그런데 다루는 경우의 수가 많아 질 수록 if 표현식이 점점 더 복잡해질 것이다. 예를 들어, 아래 예제와 같은 경우에는 if 표현식은 가급적 사용하지 않아야 한다.

예제

인자가 1부터 5까지면 해당 숫자를 문자열로 반환하고, 그렇지 않으면 "Not between 1 and 5" 를 출력하는 함수를 패턴 매칭을 이용하여 간단하게 정의할 수 있다.

In [24]:
sayMe :: (Integral a) => a -> String
sayMe 1 = "One!"
sayMe 2 = "Two!"
sayMe 3 = "Three!"
sayMe 4 = "Four!"
sayMe 5 = "Five!"
sayMe x = "Not between 1 and 5"

불완전 패턴 매칭

패턴 매치은 가능한 모든 경우를 다루어야 한다. 그렇지 않으면 오류가 발생할 수 있다. 아래 함수는 불완전한 패턴 매칭을 사용한다. a, b, c 이외의 문자에 대한 정의가 누락되어 있기 때문이다.

In [25]:
charName :: Char -> String
charName 'a' = "Albert"
charName 'b' = "Broseph"
charName 'c' = "Cecil"

a, b, c 세 문자에 대해서는 잘 작동한다.

In [26]:
charName 'a'
"Albert"
In [27]:
charName 'b'
"Broseph"
In [28]:
charName 'c'
"Cecil"

다른 문자에 대해서는 오류가 발생한다. 이유는 기타 문자에 대해서는 어떤 패턴 매칭도 정의되어 있지 않기 때문이다.

In [29]:
charName 'h'
<interactive>:(2,1)-(4,22): Non-exhaustive patterns in function charName

따라서 패턴 매칭을 활용할 때 항상 '기타'에 해당하는 경우를 맨 마지막 패턴 매칭으로 지정하는 것을 잊지 말아야 한다. 물론 아래와 같이 기타의 경우가 없는 경우도 있기도 하다.

In [30]:
sayTruth :: Bool -> String
sayTruth True  = "True"
sayTruth False = "False"
In [31]:
sayTruth (charName 'c' == "Cecil")
"True"
In [32]:
sayTruth (7 > 10)
"False"

튜플 패턴 매칭

하스켈의 모든 자료형(data type)에 대해 패턴 매칭을 사용할 수 있다. 앞서 숫자와 문자에 대한 패턴 매칭을 살펴 보았는데, 이제 튜플에 대한 패턴 매칭 사용법을 예제를 이용하여 확인한다.

예제

2차원 벡터 두 개의 덧셈을 구해주는 함수를 정의하자. 단, 벡터를 길이가 2인 튜플로 구현되어 있다고 가정한다. 튜플에 대한 패턴 매칭은 튜플의 모양에 따라 지정한다. 예를 들어, 길이가 2인 튜플은 아래 모양을 갖는다.

(x, y)

위 정보를 이용하여 2차원 벡터의 덧셈 함수를 정의하면 다음과 같으며, 두 인자 모두에 대해 패턴 매칭을 적용하였다. (클래스 제약인 (Num a)에는 너무 큰 신경을 쓰지 않아도 된다.)

In [33]:
addVectors :: (Num a) => (a, a) -> (a, a) -> (a, a)
addVectors (x1, y1) (x2, y2) = (x1 + x2, y1 + y2)

위 정의에서 하나의 패턴만 사용되었어도 충분한 이유는 두 인자 모두 (a, a) 유형으로 선언되어 있어서 튜플만 인자로 올 수 있기 때문이다. 반면에 패턴 매칭을 사용하지 않으려면 아래처럼 fstsnd 함수를 이용할 수 있지만 상대적으로 덜 직관적이다.

In [34]:
addVectors :: (Num a) => (a, a) -> (a, a) -> (a, a)
addVectors a b = (fst a + fst b, snd a + snd b)
In [35]:
addVectors (2, 3) (4, 7)
(6,10)

예제

앞서 언급한 fstsnd 함수는 길이가 2인 튜플에 대해서만 동작한다. 그런데 패턴 매칭을 이용하면 길이가 3 이상인 튜플에서 각 항목을 추출하는 함수를 간단하게 정의할 수 있다. 예를 들어, 길이가 3인 튜플의 패턴은 다음과 같다.

(x, y, z)

다음 first, second, third 함수는 위 패턴을 이용하여 길이가 3인 튜플에서 각각 첫째, 둘째, 셋째 항목을 추출해준다. 각 함수의 경우 지정된 위치 이외의 항목은 전혀 관심대상이 아니기에 이전에 리스트 조건제시법을 설명할 때 언급한 것처럼 와일드카드인 밑줄(underscore)로 표시하였다.

In [36]:
first :: (a, b, c) -> a
first (x, _, _) = x

second :: (a, b, c) -> b
second (_, y, _) = y

third :: (a, b, c) -> c
third (_, _, z) = z
In [37]:
first ("abc", 1, [7 >10 , 2+3 == 5])
"abc"
In [38]:
second ("abc", 1, [7 >10 , 2+3 == 5])
1
In [39]:
third ("abc", 1, [7 >10 , 2+3 == 5])
[False,True]

리스트 조건제시법과 패턴 매칭

패턴 매칭은 가능한 모든 곳에서 활용된다. 아래 정의는 리스트 조건제시법의 조건식에서 패턴 매칭을 이용하는 것을 보여준다.

In [40]:
let xs = [(1,3), (4,3), (2,4), (5,3), (5,6), (3,1)]
[a+b | (a,b) <- xs]
[4,7,6,8,11,4]

리스트 조건 제시법에서 패턴 매칭을 사용한 경우, 특정 항목에 대해 패턴 매칭이 실패하면 그냥 다음 항목으로 넘어간다. 예를 들어, 아래의 경우 길이가 2인 리스트에 대해서만 패터 매칭이 적용되며, 다른 항목은 무시된다.

In [41]:
ys = [[1,3], [4,3,2], [2,4,1,2], [5,3]]
[a+b | [a,b] <- ys]
[4,8]

리스트 패턴 매칭

예를 들어 [x1,x2,...,xk]의 원래 정의는 다음과 같다. (단, k는 음이 아닌 정수. k=0이면, 공리스트를 표현함.)

x1:x2:...:xk:[]

즉, 임의의 리스트는 공리스트 [] 이거나 아니면 x:xs 형식을 갖는다. 여기서 x는 리스트의 머리를, xs는 머리를 제외한 나머지 항목으로 이루어진 리스트를 가리킨다. 물론 xs 자체는 공리스트가 될 수도 있다.

따라서 리스트에 대해서는 아래 두 경우가 가장 일반적인 패턴으로 사용된다.

  • 공리스트 패턴: [], 또는 : 연산과 공리스트를 포함한 어떤 패턴에서든 사용할 수 있음.
  • 공리스트가 아닌 패턴: x:xs

이외에 주어진 리스트 또는 원하는 리스트의 패턴에 맞춘 경우를 지정하여 사용할 수도 있다. 예를 들어, 세 개 이상의 항목을 갖는 리스트에 대한 패턴은 다음과 같이 지정한다.

x:y:z:zs

head 함수를 아래와 같이 직접 정의할 수 있다.

In [42]:
head' :: [a] -> a
head' [] = error "Can't call head on an empty list, dummy!"
head' (x:_) = x

x:xs 대신에 와일드카드를 이용하여 x:_를 지정한 이유는 xs의 역할이 없기 때문이다. 우리가 알고 있는 head 함수와 동일하게 작동함을 쉽게 확인할 수 있다.

참고

  • 패턴에 와일드카드를 포함하여 여러 개의 변수가 사용되는 경우 패턴 전체를 소괄호로 감싸야 한다. 그렇지 않으면 오류가 발생한다.

  • error 함수: 프로그램 실행중 고의로 오류를, 즉 런타임 에러를 발생시키면서 동시에 인자로 지정된 문자열을 이용하여 발생한 오류에 대한 정보를 전달한다. 프로그램을 중단시키기 때문에 많이 사용하지는 않아야 한다. 하지만 공리스트의 머리를 요구하는 일은 당연히 피해야 할 일이다.

In [43]:
head' [4,5,6]
4
In [44]:
head' "Hello"
'H'
In [45]:
head' []
Can't call head on an empty list, dummy!
CallStack (from HasCallStack):
  error, called at <interactive>:2:12 in interactive:Ghci1171

예제

리스트의 첫 두 항목까지를 읽어주는 함수의 패턴 매칭은 아래의 경우로 이루어진다.

  • []: 공리스트
  • (x:[]): 길이 1인 리스트
  • (x:y:[]): 길이 2인 리스트
  • (x:y:_): 기타의 경우, 즉, 길이가 3 이상인 리스트. 셋째 항목부터는 관심 대상이 아니기에 밑줄로 표시함.
In [46]:
tell :: (Show a) => [a] -> String
tell [] = "The list is empty."
tell (x:[]) = "The list has one element: " ++ show x
tell (x:y:[]) = "The list has two elements: " ++ show x ++ " and " ++ show y
tell (x:y:_) = "This list is long. The first two elements are: " ++ show x ++ " and " ++ show y
In [47]:
tell []
"The list is empty."
In [48]:
tell [3]
"The list has one element: 3"
In [49]:
tell [3, 7]
"The list has two elements: 3 and 7"
In [50]:
tell [3, 7, 9]
"This list is long. The first two elements are: 3 and 7"

as 패턴

인자의 패턴과 인자 자체를 가리키는 변수를 함께 지정할 때 유용하게 사용되는 방법이다. 사용법은 다음과 같다.

변수이름@(패턴)

예를 들어, xs@(x:y:ys)x:y:ys와 정확히 매칭되는 인자를 지원하며 동시에 해당 인자를 xs에 연결한다. 따라서 함수 본문에 인자 자체를 사용하려면 xs를 대신 입력하면 된다.

앞서 정의한 tell 함수를 as 패턴으로 정의하면 다음과 같다.

In [51]:
tell :: (Show a) => [a] -> String
tell xs@[] = "The list " ++ show xs ++ " is empty."
tell xs@(x:[]) = "The list " ++ show xs ++ " has one element: " ++ show x
tell xs@(x:y:[]) = "The list " ++ show xs ++ " has two elements: " ++ show x ++ " and " ++ show y
tell xs@(x:y:_) = "The list " ++ show xs ++ " is long. The first two elements are: " ++ show x ++ " and " ++ show y
In [52]:
tell []
"The list [] is empty."
In [53]:
tell [3]
"The list [3] has one element: 3"
In [54]:
tell [3, 7]
"The list [3,7] has two elements: 3 and 7"
In [55]:
tell [3, 7, 9]
"The list [3,7,9] is long. The first two elements are: 3 and 7"

재귀함수와 패턴 매칭

패턴 매칭을 활용하면, 재귀함수의 작동 구조를 보다 쉽게 이해할 수 있도록 정의할 수 있다.

계승(n!)을 계산하는 함수를 이전에 아래와 같이 정의하였다.

In [56]:
factorial :: (Integral a) => a -> a
factorial n = product [1..n]

위 함수를 재귀적으로 구현하면 다음과 같다. 재귀함수를 작성할 때 아래 두 사항을 고려해야 한다.

  • 시작단계: 인자와 반환값을 구체적으로 지정하는 단계
  • 재귀단계: 인자를 특정 패턴으로 지정한 후 패턴의 구성 요소에 대한 반환값을 활용하는 단계

계승 함수를 재귀적으로 정의하려면 다음 특성에 주목한다.

$$ n! = \begin{cases} 1, & n = 0 \\ n\cdot (n-1)!, & n \text{은 양의 정수} \end{cases} $$

예를 들어, 3!는 아래 과정을 통해 계산된다.

3! => 3 * 2! 
   => 3 * (2 * 1!)
   => 3 * (2 * (1 * 0!))
   => 3 * (2 * (1 * 1))

따라서 계승 함수의 재귀적 정의에 필요한 시작단계와 재귀단계는 다음과 같다.

  • 시작단계: 인자가 0일 때 값을 1로 지정
  • 재귀단계: 지정된 인자보다 1작은 값에 대한 함수값 factorial (n-1) 활용
In [57]:
factorial :: (Integral a) => a -> a
factorial 0 = 1
factorial n = n * factorial (n - 1)

예제

리스트의 길이를 반환하는 length 함수를 직접 재귀함수로 정의하기 위해 다음 두 단계를 패턴으로 다룬다.

  • 시작단계: 공리스트의 경우 0
  • 재귀단계: 한 개 이상의 항목을 갖는 리스트의 경우 머리를 제외한 꼬리 리스트의 길이에 1을 더한 값

따라서 다뤄야 하는 패턴은 두 가지이다.

  • 공리스트 패턴: []
  • 공리스트가 아닌 패턴: x:xs

실제로 구현할 때는 x:xs 대신 _:xs 를 사용한다. 이유는 머리의 이름이 리스트의 길이를 계산할 때 직접 활용되지는 않기 때문이다. 반환값은 숫자이어야 하기에 Num 유형 클래스의 유형으로 일반화하여 지정한다.

In [58]:
length' :: (Num b) => [a] -> b
length' [] = 0
length' (_:xs) = 1 + length' xs

문자열 "ham"의 길이를 재귀적으로 구하는 과정은 다음과 같다.

length' "ham" => 1 + length' "am"
              => 1 + (1 + length' "m")
              => 1 + (1 + (1 + length' ""))
              => 1 + (1 + (1 + length' []))
              => 1 + (1 + (1 + 0))
              => 3

함수의 유형을 지정하기 어려운 경우, 함수의 본체만 정의해도 된다. 그러면 하스켈이 직접 함수의 유형을 추론한다. length 함수를 아래처럼 유형 없이 정의한 후 바로 유형을 확인할 수 있다.

In [59]:
length' [] = 0
length' (_:xs) = 1 + length' xs
In [60]:
:t length'
length' :: forall p a. Num p => [a] -> p

참고: 유형을 확인할 때 보여지는 forall p a 는 "임의의 유형 p와 a에 대해" 라는 의미이며, 있는 것과 없는 것이 동일한 의미를 나타낸다. 예를 들어, forall a 등의 표현이 사용되지 않았더라도, 표현식이 a 유형 변수가 사용되었다면 그 의미는 "임의의 유형 a에 대해" 라는 것을 함의한다.

예제

리스트에 포함된 수들의 합을 반환하는 sum 함수를 직접 재귀함수로 구현하는 방법도 동일한 재귀 패턴을 사용한다.

  • 시작단계: 공리스트 경우 0.
  • 재귀단계: 한 개 이상의 항목을 갖는 리스트의 경우 머리를 제외한 꼬리 리스트에 포함된 항목들의 합과 머리를 더한 값

실제로 구현할 때는 머리도 중요한 역할을 수행하기에 머리에 해당하는 변수도 명시적으로 사용한다. 리스트의 항목이 덧셈을 지원해야 하기에 Num 유형 클래스의 유형으로 일반화하여 지정한다.

In [61]:
sum' :: (Num a) => [a] -> a
sum' [] = 0
sum' (x:xs) = x + sum' xs

주의사항: 두 개의 리스트를 이어붙인 리스트를 반환하는 ++ 에 대해 패턴 매칭을 사용할 수 없다. 이유는 공리스트가 아닌 어떠한 리스트도 유일한 방식으로 머리와 꼬리로 구분할 수 있는 반면에, (xs ++ ys) 패턴으로 구분하는 방법은 일반적으로 유일하지 않기 때문이다. 예를 들어, [1, 2, 3][] ++ [1, 2, 3], [1] ++ [2, 3], [1, 2] ++ [3], [1, 2, 3] ++ [] 등 다양한 방식으로 표현할 수 있다.

case 표현식

(매개)변수가 가리킬 수 있는 값에 따라 다른 계산을 수행하도록 지정할 때 사용하는 표현식이다. 이때 변수가 가리키는 다른 값은 패턴 매칭을 사용하여 구분한다. case 표현식의 일반적인 구문은 다음과 같다.

case expression of pattern -> result
                   pattern -> result
                   pattern -> result
                   ...

head 함수를 예를 들어 아래처럼 정의할 수 있다.

  • 매개변수 xs가 가질 수 있는 값들의 패턴에 따른 계산을 case 표현식으로 다루었다.
In [62]:
head' :: [a] -> a
head' xs = case xs of 
                []    -> error "No head for empty lists!"
                (x:_) -> x

위 정의는 함수를 정의할 때 사용하는 패턴 매칭과 동일한 의미를 갖는다. 실제로 아래 정의는 위 정의를 간략하게 표현한 것에 불과하다.

In [63]:
head' :: [a] -> a
head' []    = error "No head for empty lists!"
head' (x:_) = x

함수 f를 정의할 때 사용되는 패턴이 아래 모양이라고 가정하자.

f p11 ... p1k = e1
f p21 ... p2k = e2
f p31 ... p3k = e3
...

위에서 pij 는 각각 특정 패턴을 나타낸다고 가정한다. 그러면 함수 f를 아래처럼 case 표현식을 이용하여 의미상 동일하게 구현한 것이다.

f x1 ... xk = case (x1, ..., xk) of f p11 ... p1k -> e1
                                    f p21 ... p2k -> e2
                                    f p31 ... p3k -> e3
                                    ...

case 표현식 일반 사용법

함수의 매개변수에 대한 패턴 매칭은 함수를 정의할 때만 사용될 수 있다. 반면에 case 표현식은 거의 어디에서든 사용가능하다. 예를 들어, 아래에서처럼 임의의 표현식 중간에 위치할 수도 있다. 이것이 가능한 이유는 case 표현식이 명령문 등에 사용되는 구문이 아니고 그 자체로 표현식이기 때문이다.

In [64]:
describeList :: [a] -> String
describeList xs = "The list is " ++ case xs of []  -> "empty."
                                               [x] -> "a singleton list."
                                               xs  -> "a longer list."

case 표현식과 if 표현식

if 표현식은 참, 거짓 두 경우로 이루어진 case 표현식를 간략하게 표현한 것에 불과하다. 실제로

if e1 then e2 else e3

는 아래 표현식에 해당한다.

case e1 of True  -> e2
           False -> e3

참고: C, C++, Java 등의 언어에서 지원하는 case 구문과 유사하게 작동한다. 하지만 하스켈에서는 명령문에 사용되는 구문이 아니라 특정 값을 나타내는 표현식이라는 차이를 갖는다.

4.2 가드(Guards)

패턴이 값의 형태에 따른 계산과정을 지정하는 반면에, 가드(guards)는 값의 성질에 따라 계산과정을 지정한다. 즉, 패턴은 구문 분석 결과에 따라 경우를 분류하고, 가드는 의미 분석 결과에 따라 경우를 분류한다. 가드의 기능은 if 표현식의 그것과 유사하지만 여러 경우를 동시에 다룰 수 있다는 점에서 보다 높은 가독성을 지원한다.

예를 들어, 체질량지수(BMI, Body Mass Index)를 18.5 이하, 25.0 이하, 30.0 이하, 기타의 네 경우에 대해 각기 다른 문장을 반환하는 함수를 정의해보자.

  • BMI = 몸무게(kg)를 키(m)의 제곱으로 나눈 값

함수 정의를 위해 아래 네 경우를 다루어야 한다.

  • BMI <= 18.5
  • 18.5 < BMI <= 25.0
  • 25.0 < BMI <= 30.0
  • 30.0 < BMI

위 네 경우를 가드로 표현하면 다음과 같다.

In [65]:
bmiTell :: (RealFloat a) => a -> String
bmiTell bmi
    | bmi <= 18.5 = "You're underweight, you emo, you!"
    | bmi <= 25.0 = "You're supposedly normal. Pffft, I bet you're ugly!"
    | bmi <= 30.0 = "You're fat! Lose some weight, fatty!"
    | otherwise   = "You're a whale, congratulations!"

앞서 사용된 가드의 용법은 다음과 같다.

  • 함수 이름과 매개변수를 먼저 적는다.

  • 줄바꿈과 들여쓰기(indent)를 한 다음에 막대기(파이프, |)를 사용하여 경우를 구분한다.

  • 막대기 기호 오른편에 는 부울 논리식, 즉, 참과 거짓으로 판명될 수 있는 표현식이 사용되며, 사용되는 논리식을 가드라 부른다.

    • 가드가 참(True)과 동치이면, 등호 기호 오련편에 지정된 값을 반환한다.
    • 가드가 거짓(False)과 동치이면, 다음 가드로 넘어간다.
  • 둘째 가드를 18.5 < bmi <= 25.0 대신에 bmi <= 25.0으로 작성한 이유는, 이미 첫째 가드가 성립하지 않는다는 것을 전제하기 때문이다. 셋째 가드 또한 동일하다.

  • 마지막 가드는 기타의 경우를 담당하는 otherwise가 사용된다. 물론, 이전에 모든 경우를 다루었다면 필요하지 않다.

참고

위에서 bmiTell 함수의 유형을 지정할 때 사용된 RealFloat 유형 클래스는 실수와 관련된 숫자들의 유형들로 이루어진 클래스이다. 또한 함수의 본문에 크기비교가 사용되기에 유형 aOrd 유형 클래스에 속해야 한다. 하지만 RealFloat 유형 클래스가 Ord 유형 클래스를 확장하기에 굳이 따로 지정하지 않아도 된다.

앞서 언급한 대로 아래처럼 함수의 유형을 지정하지 않아도 된다. 함수 정의 후에 유형을 확인하면 유형 aOrdFractional 유형 클래스에 포함되면 충분하다는 것이 확인된다. Fractional 유형 클래스는 나눗셈에 대해 닫혀 있는 수들의 집합을 유형으로 포함한다. 여기에는 당연히 실수의 유형도 포함된다.

In [66]:
bmiTell bmi 
    | bmi <= 18.5 = "You're underweight, you emo, you!"
    | bmi <= 25.0 = "You're supposedly normal. Pffft, I bet you're ugly!"
    | bmi <= 30.0 = "You're fat! Lose some weight, fatty!"
    | otherwise   = "You're a whale, congratulations!"
In [67]:
:t bmiTell
bmiTell :: forall a. (Ord a, Fractional a) => a -> [Char]

패턴 매칭과 가드

패턴과 가드를 혼합해서 사용할 수 있다. 예를 들어, 리스트에 포함된 항목 중에서 양의 값만 세는 함수는 다음과 같다.

In [68]:
countPositives :: (Ord a, Num a, Num b) => [a] -> b
countPositives [] = 0
countPositives (x:xs) | x > 0     = 1 + countPositives xs
                      | otherwise = countPositives xs
In [69]:
countPositives [0, 2, 3, 0, 6]
3
In [70]:
countPositives [1, 2, 3, 1, 6]
5

가드 기본 사용법

가드는 여러 개의 인자를 가진 함수에서도 사용할 수 있다. 예를 들어, BMI를 직접 입력하는 대신에 몸무게(weight)와 키(height) 두 개의 값을 사용하도록 할 수 있다. 그러면, 가드는 weightheight 두 변수를 사용하는 논리식으로 지정된다.

In [71]:
bmiTell :: (RealFloat a) => a -> a -> String
bmiTell weight height
    | weight / height^2 <= 18.5 = "You're underweight, you emo, you!"
    | weight / height^2 <= 25.0 = "You're supposedly normal. Pffft, I bet you're ugly!"
    | weight / height^2 <= 30.0 = "You're fat! Lose some weight, fatty!"
    | otherwise                   = "You're a whale, congratulations!"

나의 체질량지수는 정상이라고 판정된다.

In [72]:
bmiTell 70 1.78
"You're supposedly normal. Pffft, I bet you're ugly!"

예제

비교될 수 있는 두 개의 값 중에서 더 큰 값을 반환하는 max 함수를 직접 구현한다.

In [73]:
max' :: (Ord a) => a -> a -> a
max' a b
    | a > b     = a
    | otherwise = b

가드를 위한 들여쓰기는 필수이며 들여쓰기 정도를 일정하게 맞추어야 한다. 반면에 줄바꿈은 선택사항이다. 물론 아래와 같이 모든 것을 한 줄에 작성하면 가독성이 별로 좋지 않아서 가급적 줄바꿈을 사용해야 한다.

In [74]:
max' :: (Ord a) => a -> a -> a
max' a b | a > b = a | otherwise = b

예제

두 값의 크기를 비교하는 compare 함수를 직접 구현한다.

In [75]:
myCompare :: (Ord a) => a -> a -> Ordering
a `myCompare` b | a > b     = GT
                | a == b    = EQ
                | otherwise = LT
In [76]:
3 `myCompare` 2
GT

참고: 위 정의에서 가독성을 높이기 위해 백틱 기호(`, backtick)를 사용하여 myCompare 함수를 중위 함수 형식으로 정의하였다.

4.3 where 절(clause)과 let 표현식

몸무게와 키 두개의 인자를 받아 체질량지수를 계산하는 함수 bmiTell 함수를 앞서 아래와 같이 정의하였다.

In [77]:
bmiTell :: (RealFloat a) => a -> a -> String
bmiTell weight height
    | weight / height^2 <= 18.5 = "You're underweight, you emo, you!"
    | weight / height^2 <= 25.0 = "You're supposedly normal. Pffft, I bet you're ugly!"
    | weight / height^2 <= 30.0 = "You're fat! Lose some weight, fatty!"
    | otherwise                   = "You're a whale, congratulations!"

그런데 위 함수는 아래 표현식이 반복적으로 언급되었다.

weight / height^2

이런 경우에 위 표현식에 이름을 주어 상수로 지정할 수 있으며, 단축 이름은 where 절에서 지정한다.

In [78]:
bmiTell :: (RealFloat a) => a -> a -> String
bmiTell weight height
    | bmi <= 18.5 = "You're underweight, you emo, you!"
    | bmi <= 25.0 = "You're supposedly normal. Pffft, I bet you're ugly!"
    | bmi <= 30.0 = "You're fat! Lose some weight, fatty!"
    | otherwise   = "You're a whale, congratulations!"
    where bmi = weight / height^2

where 절 사용법

where 절은 하나의 패턴 내에서 사용되며, 가드와 동일한 수준으로 들여써서 사용한다. 위 정의의 bmi를 지정한 것처럼 특정 표현식에 이름을 붙임으로써 가독성을 높이고, 해당 표현식의 값이 한 번만 계산되게 함으로써, 프로그램의 실행속도를 높혀준다. 또한 필요한 경우 지정된 이름에 다른 값을 지정하면 함수 자체를 더 이상 수정할 필요가 없어서 프로그래밍 효율성도 함께 높아진다.

여러 개의 상수 또는 함수를 where 절에서 정의할 수 있으려, 일반적으로 아래 형식을 갖는다.

where 바인딩1
      바인딩2
      바인딩3
      ...

바인딩(binding)의 형식은 상수 또는 함수를 정의하는 형식과 동일하다. 아래 예제는 네 개의 바인딩을 사용하며 각각 bmi, skinny, normal, fat 등 총 4개의 상수를 정의하는 것을 보여준다. 바인딩의 열맞춤에 주의해야 한다.

In [79]:
bmiTell :: (RealFloat a) => a -> a -> String
bmiTell weight height
    | bmi <= skinny = "You're underweight, you emo, you!"
    | bmi <= normal = "You're supposedly normal. Pffft, I bet you're ugly!"
    | bmi <= fat    = "You're fat! Lose some weight, fatty!"
    | otherwise     = "You're a whale, congratulations!"
    where bmi = weight / height^2
          skinny = 18.5
          normal = 25.0
          fat = 30.0

where 절의 영향력

특정 함수를 정의할 때 사용되는 where 절에서 정의된 상수와 함수는 해당 함수 내에서만 사용할 수 있다. 심지어 한 함수 내에서도 하나의 패턴 내에서만 영향을 갖는다. 즉, where 절에서 정의된 상수와 함수의 활동영역(scope)이 매우 제한적이다. 여러 패턴, 여러 함수에서 공유될 수 있는 이름은 전역 함수로 정의해야 한다.

where 절과 패턴 매칭

where 절 내에서 패턴 매칭을 사용할 수 있다. 예를 들어, bmiTell 함수를 아래와 같이 정의할 수 있다.

In [80]:
bmiTell :: (RealFloat a) => a -> a -> String
bmiTell weight height
    | bmi <= skinny = "You're underweight, you emo, you!"
    | bmi <= normal = "You're supposedly normal. Pffft, I bet you're ugly!"
    | bmi <= fat    = "You're fat! Lose some weight, fatty!"
    | otherwise     = "You're a whale, congratulations!"
    where bmi = weight / height ^ 2
          (skinny, normal, fat) = (18.5, 25.0, 30.0)

where 절에서 패턴 매칭을 사용하는 예를 몇 개 살펴본다. 아래 예제는 좀 작위적이지만 패턴 매칭의 활용성을 잘 보여준다.

In [81]:
initials :: String -> String -> String
initials firstname lastname = [f] ++ ". " ++ [l] ++ "."
    where (f:_) = firstname
          (l:_) = lastname

하스켈 언어는 Haskell Curry(1900-1982) 라는 미국 수리논리학자의 이름을 따서 만들었다. Haskell Curry의 약자는 다음과 같다.

In [82]:
initials "Haskell" "Curry"
"H. C."

사실 위 함수는 아래처럼 where 절을 사용하지 않으면서 함수의 인자에 대한 패턴 매칭을 직접 적용하는 게 더 간결하다.

In [83]:
initials :: String -> String -> String
initials (f:_) (l:_) = [f] ++ ". " ++ [l] ++ "."
In [84]:
initials "Haskell" "Curry"
"H. C."

물론 as 패턴 사용도 가능하다.

In [85]:
initials :: String -> String -> String
initials firstname@(f:_) lastname@(l:_) = [f] ++ ". " ++ [l] ++ "."
In [86]:
initials "Haskell" "Curry"
"H. C."

앞서 정의한 bmiTell 함수에서 사용하는 bmi 값은 하나의 값으로 고정된다. 변하는 값을 다루기 위해서는 아래와 같은 체질량지수를 반환하는 함수가 필요하다.

bmi weight height = weight / height^2

다행히도 where 절에서 함수를 정의할 수 있다. 아래 calcBmis 함수는 여러 명의 체중과 키 정보가 담긴 리스트를 이용하여 각자의 체질량지수를 계산한다.

주의: 리스트 조건제시법을 활용한다.

In [87]:
calcBmis :: (RealFloat a) => [(a, a)] -> [a]
calcBmis xs = [bmi w h | (w, h) <- xs]
    where bmi weight height = weight / height^2

몸무게와 키가 각각 다음과 같은 세 명의 체질량지수를 계산해보자.

In [88]:
w_h_List = [(65, 1.60), (95, 1.87), (45, 1.67)]
In [89]:
calcBmis w_h_List
[25.390624999999996,27.166919271354622,16.1353938828929]

체질량지수를 직접 확인하는 것 보다는 bmiTell 함수를 활용하면 결과를 보다 쉽게 파악할 수도 있다. 하지만 이전 정의에서는 반환값으로 사용된 문자열이 좀 수다스럽기에 여기서는 조금 간결하게 다시 정의해서 사용한다.

In [90]:
bmiTell :: (RealFloat a) => a -> String
bmiTell bmi
    | bmi <= 18.5 = "Underweight"
    | bmi <= 25.0 = "Normal"
    | bmi <= 30.0 = "Fat"
    | otherwise   = "A whale"

bmiTell 함수를 활용하는 두 가지 방법을 소개한다. 한 가지는 calcBmis 함수를 수정하는 방식이고 다른 하나는 그러지 않는 방식이다.

먼저 수정하지 않는 방식은 map 함수를 활용한다.

In [91]:
map bmiTell (calcBmis w_h_List)
["Fat","Fat","Underweight"]

map 함수의 활용법은 나중에 고계함수(higher-order function)을 소개할 때 자세히 설명한다. 여기서는 리스트 각각의 항목에 대해 지정된 함수를 실행한 결과값으로 이루어진 리스트를 반환한다는 정도만 기억해두면 좋다. 참고로 map 함수의 유형은 다음과 같다.

In [92]:
:t map
map :: forall a b. (a -> b) -> [a] -> [b]

위 유형의 의미는 다음과 같다.

유형 a에서 유형 b로 가는 함수와 a 유형의 리스트가 인자로 들어오면 b 유형의 리스트를 반환한다.

예를 들어, f :: a -> b 이고 [a1, a2, ..., ak] :: [a] 이면 다음이 성립한다.

map f [a1, a2, ..., ak] = [f a1, f a2, ..., f ak]

map 함수를 사용하지 않으려면 예를 들어 아래처럼 bmiTell 함수를 이용하여 calcBmis 함수를 정의할 수 있다.

In [93]:
calcBmis :: (RealFloat a) => [(a, a)] -> [String]
calcBmis xs = [bmiTell (bmi w h) | (w, h) <- xs]
    where bmi weight height = weight / height^2
In [94]:
calcBmis [(65, 1.60), (95, 1.87), (45, 1.67)]
["Fat","Fat","Underweight"]

where 절 안에서 패턴 매칭을 이용한 함수 정의하기 또한 가능하다. 앞서 case 표현식을 설명할 때 정의했던 describeList 함수는 다음과 같다.

In [95]:
describeList :: [a] -> String
describeList xs = "The list is " ++ case xs of []  -> "empty."
                                               [x] -> "a singleton list."
                                               xs  -> "a longer list."

위 함수를 where 절를 이용하여 다음과 같이 재정의 할 수 있다.

In [96]:
describeList :: [a] -> String
describeList xs = "The list is " ++ what xs
    where what []  = "empty."
          what [x] = "a singleton list."
          what xs  = "a longer list."

중첩 where

함수의 정의를 너무 복잡하게 만들기에 권장되지는 않지만 where 절을 중첩해서 사용할 수 있다. 예를 들어, 위 describeList 함수는 리스트의 길이가 2 이상이면 'long list'라는 내용을 반환한다. 아래 함수는 길이가 2이면 'two-item list', 길이가 3 이상일 때 'longer than two'라는 내용을 반환하도록 수정하였다.

주의사항: where 절은 하나의 패턴 내에서 사용되기에, where 절을 중첩으로 사용하려면 먼저 패턴을 중첩으로 사용해야 한다.

In [97]:
describeList' :: [a] -> String
describeList' xs = "The list is " ++ what xs
    where what []  = "empty."
          what [x] = "a singleton list."
          what xs 
              | length xs == 2 = "a two-item list."
              | otherwise      = reallyLong
              where reallyLong = "longer than two."
In [98]:
describeList' []
"The list is empty."
In [99]:
describeList' [1]
"The list is a singleton list."
In [100]:
describeList' [1,2]
"The list is a two-item list."
In [101]:
describeList' [1,2,3]
"The list is longer than two."

위 정의는 중첩 where 절의 사용법을 보여주기 위해 매우 인위적으로 작성하였다. 앞서 언급하였듯이 where 절을 포함하여 if 표현식, 패턴 매칭 등을 중첩으로 사용하는 것은 권장되지 않는다. 실제로 많은 경우 굳이 중첩 사용을 피할 수 있다. 위 함수의 경우도 사실은 아래와 같이 정의하면 보다 이해하기 쉽다.

In [102]:
describeList'' :: [a] -> String
describeList'' xs = "The list is " ++ what xs
    where what []     = "empty."
          what [x]    = "a singleton list."
          what [x, y] = "a two-item list."
          what xs     = "longer than two."
In [103]:
describeList'' []
"The list is empty."
In [104]:
describeList'' [1]
"The list is a singleton list."
In [105]:
describeList'' [1,2]
"The list is a two-item list."
In [106]:
describeList'' [1,2,3]
"The list is longer than two."

let 표현식

let 표현식의 기본적인 형태는 다음과 같다. (바인딩은 앞서 where 절에서 설명한 것과 동일함)

let 바인딩1
    바인딩2
    바인딩3
    ...
in 표현식

형식에서 알아볼 수 있듯이 let 표현식은 좁은 영역(local)에서 사용될 수 있는 상수 또는 함수를 지정한다. 예를 들어, 원기둥의 높이와 반지름을 이용해서 원기둥의 겉넓이를 구해주는 함수는 다음과 같다. (함수 정의 패턴 매칭에 사용되는 변수를 활용하였음)

In [107]:
cylinder :: (RealFloat a) => a -> a -> a
cylinder r h = let sideArea = 2 * pi * r * h
                   topArea = pi * r ^2
               in  sideArea + 2 * topArea
In [108]:
cylinder 2 3
62.83185307179586

let 표현식 대신에 where 절을 이용할 수 있다.

In [109]:
cylinder' :: (RealFloat a) => a -> a -> a
cylinder' r h = sideArea + 2 * topArea
               where sideArea = 2 * pi * r * h
                     topArea = pi * r ^2
In [110]:
cylinder' 2 3
62.83185307179586

let 표현식의 특징

앞서 살펴 보았듯이 let 바인딩과 where 바인딩은 기본적으로 동일한 역할을 수행한다. 다만 다음 두 가지 측면에서 서로 다른 특성을 갖는다.

첫째, where 절은 함수의 본문 끝에 위치하며 따라서 사용되는 바인딩이 함수 전체에 영향을 미친다. 반면에 let 바인딩에서 정의되는 상수 및 함수는 in 키워드 다음에 위치한 표현식에서만 사용할 수 있다. 따라서 let 바인딩의 적용 범위(scope)는 where 절 바인딩의 적용 영역보다 보다 좁다. 따라서 임의의 where 절을 간단한 let 표현식으로 구현할 수 있는 것은 아니다. 예를 들어, 아래 함수의 경우가 그러하다.

In [111]:
f :: (Ord a, Show a, Num a) => a -> a -> String
f x y | y > z  = show y ++ "is very big."
      | y <= z = show y ++ "is not so big."
      where z = x * x

둘째, where 절은 패턴 매칭 또는 가드 등 지정된 구문 형식에만 활용될 수 있는 반면에, let 표현식은 if 표현식처럼 임의의 표현식에 활용될 수 있다.

In [112]:
[if 5 > 3 then "Woo" else "Boo", if 'a' > 'b' then "Foo" else "Bar"]
["Woo","Bar"]
In [113]:
4 * (if 10 > 5 then 10 else 0) + 2
42
In [114]:
4 * (let a = 9 in a + 1) + 2
42
In [115]:
[let square x = x * x in (square 5, square 3, square 2)]
[(25,9,4)]

let 표현식 활용법

아래 예제들은 let 표현식의 다양한 활용법을 보여준다.

  • 세미콜론 활용: 여러 개의 바인딩을 세미콜론(;)으로 구분하며 한 줄로 작성할 수 있으며, 마지막 바인딩 뒤에는 세미콜론을 사용하지 않아도 된다.
In [116]:
(let a = 100; b = 200; c = 300 in a*b*c, let foo="Hey "; bar = "there!" in foo ++ bar)
(6000000,"Hey there!")
  • 패턴 매칭 활용: let 바인딩과 함께 패턴 매칭을 사용할 수 있다. 예를 들어, 튜플 패턴 매칭을 이용하면 항목별 이름을 간단하게 지정하여 활용할 수 있다.
In [117]:
(let (a,b,c) = (1,2,3) in a+b+c) * 100
600
  • 리스트 조건제시법 활용: 리스트 조건제시법을 정의할 때 let 바인딩을 사용할 수 있다. 예를 들어, 아래와 같이 정의되었던 calcBmis 함수를 let 바인딩을 이용하여 정의해보자.
In [118]:
calcBmis :: (RealFloat a) => [(a, a)] -> [String]
calcBmis xs = [bmiTell (bmi w h) | (w, h) <- xs]
    where bmi weight height = weight / height^2

방법은 where 절을 let 표현식으로 바꾸어 조건제시법 내부로 끌어 들이기만 하면 된다. 결과는 다음과 같다.

In [119]:
calcBmis :: (RealFloat a) => [(a, a)] -> [a]
calcBmis xs = [bmi | (w, h) <- xs, let bmi = w / h^2]

위 조건제시법에 사용된 let 바인딩은 다음과 같이 이해해야 한다.

  • let 바인딩은 리스트에 포함되는 항목을 필터링하는 용도로 사용되지 않는다.
  • let 바인딩에서 정의된 이름은 파이프 기호 왼편에 위치한 항목 표현식에 사용될 수 있다.

또한 아래 기능도 기억해두어야 한다.

  • let 바인딩에서 정의된 이름은 이후에 지정되는 조건식에서 활용될 수 있다. 하지만 이전에 지정된 조건식에서는 사용될 수 없다. 예를 들어, 위 조건제시법에서 bmi라는 이름을 (w, h) <- xs 부분에서는 사용할 수 없다.

위 기능을 이용하여 뚱뚱한 사람들의 BMI만 반환하는 함수를 아래와 같이 정의할 수 있다.

In [120]:
calcBmis' :: (RealFloat a) => [(a, a)] -> [a]
calcBmis' xs = [bmi | (w, h) <- xs, let bmi = w / h^2, bmi >= 25.0]

참고:in 부분 생략

앞서 설명한대로 조건제시법에서의 let 바인딩의 적용 범위는 정해져 있기에 in 부분을 생략하였다. 하지만, 물론 다른 조건식에서 let ... in ... 표현식이 사용될 수도 있으며, 일반적인 let 바인딩의 적용 범위를 갖는다.

그리도 GHCi에서 전역함수나 전역상수를 직접 정의할 때에도 in 부분을 때 생략한다.

In [121]:
let zoot x y z = x * y + z
In [122]:
zoot 3 9 2
29

물론 in 부분을 사용하면 지역적(local)으로 영향을 미친다.

In [123]:
let boot x y z = x * y + z in boot 3 4 2
14
In [124]:
boot
<interactive>:1:1: error:
    • Variable not in scope: boot
    • Perhaps you meant ‘zoot’ (line 1)