2장 하스켈 프로그래밍 시작하기

아래 주피터 노트북은 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+- 커서 위치에서 셀 분할

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

2.1 간단한 연산

사칙연산

In [2]:
2 + 15
17
In [3]:
49 * 100
4900
In [4]:
1892 - 1472
420
In [5]:
5 / 2
2.5

일반적으로 알려진 연산자 우선순위가 적용된다.

In [6]:
(50 * 100) - 4999
1
In [7]:
50 * 100 - 4999
1
In [8]:
50 * (100 - 4999)
-244950

주의사항

  • 음수를 사용할 때 괄호로 묶어 주어야 한다.
  • 예를 들어, 5 * -3 를 실행하면 오류 발생하지만, 5 * (-3) 은 적절하게 실행된다.

부울 연산

부울 연산도 일반적으로 알려진대로 사용되며, 부울 연산자는 다음과 같다.

부울 연산자 의미
&& 그리고(and)
|| 또는(or)
not 부정(negation)
In [9]:
True && False
False
In [10]:
True && True
True
In [11]:
False || True
True
In [12]:
not False
True
In [13]:
not (True && True)
False

동치성 검사(Equality Test)

주어진 표현식 두 개가 동일한 값을 표현하는지 검사하기 위해 ==/= 두 기호를 사용한다.

In [14]:
5 == 5
True
In [15]:
2 + 3 == 5
True
In [16]:
1 == 0
False
In [17]:
5 /= 3 + 2
False
In [18]:
5 /= 4
True
In [19]:
"hello" == "hello"
True

주의사항: 연산에 사용되는 값들의 유형(type)에 주의해야 한다. 예를 들어, 5 + "llama" 혹은 5 == True를 실행하면 오류가 발생한다.

In [20]:
5 + "llama"
<interactive>:1:1: error:
    • No instance for (Num String) arising from a use of ‘+’
    • In the expression: 5 + "llama"
      In an equation for ‘it’: it = 5 + "llama"
In [21]:
5 == "llama"
<interactive>:1:1: error:
    • No instance for (Num String) arising from the literal ‘5’
    • In the first argument of ‘(==)’, namely ‘5’
      In the expression: 5 == "llama"
      In an equation for ‘it’: it = 5 == "llama"

오류가 발생하는 이유는 정수 5와 문자열 "llama"를 서로 더하거나 동치성 검사을 위해 비교할 수 없기 때문이다.

  • +: 동일한 유형(type)을 갖는 값들만 더할 수 있음.
  • ==: 동일한 유형(type)을 갖는 값들만을 대상으로 동치성을 검사할 수 있음.

참고: 유형(type)에 대해서는 이후에 자세히 다룬다.

반면에 5 + 4.0는 연산이 가능하며 9.0으로 계산된다. 5는 문맥에 따라 정수 또는 부동소수점으로 취급될 수 있는데 4.0 은 정수로 간주될 수 없기에 부동소수점의 합 9.0을 계산하는 것이다.

In [22]:
5 + 4.0
9.0

2.2 함수

함수 호출

앞서 살펴 본 연산 모두 함수이다. 예를 들어, * 은 두 개의 숫자를 받아 두 수의 곱을 계산하여 반환하는 함수이다.

전위 함수 대 중위 함수

+ 처럼 인자를 양쪽에 표현하는 함수를 중위 함수 라 부른다. 반면에 함수 이름 다음에 인자를 적용하는 방식으로 사용하는 함수는 전위 함수 이다. 사칙연산 등 일반적으로 사용되는 함수는 중위 함수로, 기타 일반적인 경우는 전위 함수를 사용한다.

점위 함수는 함수 이름 다음에 공백을 이용하여 매개변수 또는 인자를 이어서 작성하여 호출한다.

foo, bar 1, baz 3 "haha", bar (bar 3)

위에서 bar (bar 3) 의 경우 반드시 괄호를 사용해야 함에 주의해야 한다. 그렇지 않으면 bar 가 하나가 아닌 두 개의 인자를 받는 것으로 인식되어 오류 발생가 발생한다.

참고: 대부분의 명령형 언어에서 함수는 함수 이름을 작성한 후 괄호 안에 매개변수 또는 인자를 쉼표로 구분해 작성한다.

foo(), bar(1), baz(3, "haha"), bar(bar(3))

예를 들어, +1에 해당하는 함수인 succ의 사용법은 다음과 같다.

In [23]:
succ 8
9

여러 개의 파라미터/인자를 사용하는 함수를 호출하는 것도 앞서 설명한대로 공백을 이용하여 나열하면 된다. 예를 들어, minmax 함수는 숫자처럼 크기 순서가 비교 가능한 두 개의 값을 인자로 사용하며, 아래와 같이 호출된다.

In [24]:
min 9 10
9
In [25]:
min 3.4 3.2
3.2
In [26]:
max 100 101
101

전위 함수의 호출 우선순위

전위 함수에 인자를 적용하는 것이 가장 높은 우선순위를 갖는다. 예를 들어, 아래의 두 표현식은 동일한 결과를 계산한다.

In [27]:
succ 9 + max 5 4 + 1
16
In [28]:
(succ 9) + (max 5 4) + 1
16

따라서 9 * 10의 다음 수를 알고 싶다면, succ 9 * 10이 아니라 succ (9 * 10)로 작성해야한다.

In [29]:
succ 9 * 10
100
In [30]:
succ (9 * 10)
91

전위/중위 함수 변환

두 개의 인자를 받는 전위 함수를 역따옴표로 감싸서 중위 함수로 사용할 수 있다. 예를 들어, div 함수는 두 개의 정수를 받아와 정수 사이의 몫을 계산하는 나눗셈을 수행한다.

In [31]:
div 92 10
9

하지만 이렇게 전위 함수 형식으로 호출하면, 누가 누구를 나누는지 혼란스러울 수 있다. 그래서 이 식을 더 명확하게 하기 위해 다음과 같이 전위 함수를 역 따옴표( ` ) 기호로 감싼 후 중위 함수 형식으로 호출할 수 있다.

In [32]:
92 `div` 10
9

반면에 임의의 중위 함수를 소괄호로 감싸면 전위 함수로 활용할 수 있다.

In [33]:
(+) 1 3
4

이제 아래 동치성이 성립하는 이유를 쉽게 파악할 수 있어야 한다.

In [34]:
(1 + 2) * 3 == (*) ((+) 1 2) 3
True

함수 정의

함수를 호출하는 방법과 유사한 방식으로 함수를 정의한다.

  • 함수 이름 뒤에 공백으로 구분된 매개변수 나열한다.
  • 등호 기호(=) 오른편에 함수의 반환값을 지정하며, 함수 정의의 일반적인 형식은 다음과 같다.

    함수이름 매개변수1 ... 매개변수n = 함수반환값
    

예를 들어, 하나의 인자를 받는 함수를 정의하는 방법은 다음과 같다.

In [35]:
doubleMe x = x + x

이제 지정된 매개변수의 수만큼 인자를 사용하여 함수를 호출하면 된다.

In [36]:
doubleMe 9
18

+는 정수 뿐만 아니라 부동소수점 등 숫자로 볼 수 있는 모든 값에 대해 적용할 수 있기에 부동소수점을 doubleMe 함수에 적용할 수 있다.

In [37]:
doubleMe 8.3
16.6

두 개의 수를 인자로 받아 각 인자의 2배수의 합을 반환하는 함수는 다음과 같이 정의한다.

In [38]:
doubleUs x y = x*2 + y*2
In [39]:
doubleUs 4 9
26
In [40]:
doubleUs 2.3 34.2
73.0
In [41]:
doubleUs 28 88 + doubleMe 123
478

새로운 함수를 정의할 때, 이전에 정의한 함수를 이용할 수 있다. 예를 들어, doubleUs를 아래처럼 재정의할 수 있다.

In [42]:
doubleUs x y = doubleMe x + doubleMe y

위 정의는 간단한 기능을 수행하는 함수들을 먼저 정의한 후, 그 함수들을 결합하여 보다 복잡한 기능을 수행하는 함수를 만드는 하스켈의 전형적인 함수 정의 방식을 보여준다. 이 방식의 가장 큰 장점은 코드를 반복하여 작성하는 것을 피할 수 있다는 점이다. 예를 들어, 인자들의 2배수의 합이 아닌 3배수의 합을 구하고자 할 때 doubleMex + x + x 로 재정의하면 doubleUs는 굳이 수정할 필요가 없다.

In [43]:
doubleMe x = x*3
In [44]:
doubleUs x y = doubleMe x + doubleMe y
In [45]:
doubleUs 2 3
15

주의사항: 확장자가 hs인 하스켈 소스코드 파일을 작성한 후 컴파일하여 코드를 실행하는 경우 함수를 정의하는 순서는 아무 상관이 없다. 예를 들어, doubleUs를 먼저 정의한 후에 doubleMe를 나중에 정의해도 된다. 하지만 주피터 노트북에서는 경우에 따라 순서가 중요할 수 있다. 예를 들어 아래와 같이 하나의 셀에서 정의되는 경우 순서가 중요하지 않지만, 서로 다른 셀을 이용할 때는 정의 순서가 중요하다.

In [46]:
doubleUs' x y = doubleMe' x + doubleMe' y
doubleMe' x = x*3
doubleUs' 2 3
15

if 표현식

100보다 크지 않은 수에 대해서만 2배수를 계산하는 함수는 다음과 같이 if 표현식을 이용한다. if 표현식을 한 줄로 표현할 수 있지만, 아래처럼 여러 줄로 표현하는 것이 가독성을 높혀준다.

In [47]:
doubleSmallNumber x = if x > 100 
                        then x 
                        else x*2

만약 위의 함수에서 생성된 모든 숫자에 1을 더하고 싶다면, 아래의 코드처럼 괄호를 사용해야 한다.

In [48]:
doubleSmallNumber' x = (if x > 100 then x else x*2) + 1
In [49]:
doubleSmallNumber' 100
201
In [50]:
doubleSmallNumber' 200
201

괄호를 생략하면, x가 100보다 크지 않을 경우에만 1을 더해준다.

In [51]:
doubleSmallNumber'' x = if x > 100 then x else x*2 + 1
In [52]:
doubleSmallNumber'' 100
201
In [53]:
doubleSmallNumber'' 200
200

참고: 아포스트로피(')

아포스트로피(')는 하나의 유효한 문자이며, 함수 또는 매개변수 이름의 끝에 사용할 경우 기존에 정의된 함수 또는 매개변수를 조금 수정한 것임을 암시한다. 아포스트로피를 또한 함수 또는 매개변수의 이름 중간에 임의로 사용할 수도 있다. 단, 아포스트로피로 이름을 시작할 수는 없다.

In [54]:
conanO'Brien = "It's a-me, Conan O'Brien!"

주의사항: 위 함수의 이름에서 Conan의 이름이 소문자로 시작한다. 모든 함수의 이름은 무조건 소문자로 시작해야 한다.

참고: 변수 대 함수

conanO'Brien은 C, 자바, 파이썬 등 명령형 프로그래밍언어에서 매우 중요한 역할을 수행하는 변수가 아니라, 아무런 인자 없이 호출되어 "It's a-me, Conan O'Brien!" 라는 문자열을 반환하는 함수이다. 하스켈은 기본적으로 함수의 매개변수 이외의 변수 개념을 지원하지 않는다. 다만 명령형 언어에서 사용하는 변수의 기능을 유사하게 지원하는데, 이에 대해서는 나중에 하스켈의 명령문을 다룰 때 자세히 설명할 것이다.

참고: 표현식 대 명령문

  • 표현식(expression)은 기본적으로 값을 반환하는 코드 조각이다. 예를 들어, 5는 5를, 4 + 8은 12를, x + yxy의 합을 반환하는 표현식이다.

  • C, Java, 파이썬 등 명령형 언어에서 if 는 명령문(command)을 작성할 때 사용된다. 따라서 else 문이 선택으로 사용될 수 있다.

  • 하스켈의 if는 명령문이 아니라 값을 나타내는 표현식(statement)을 작성할 때 사용된다. 따라서 else가 필수적으로 항상 함께 해야 한다. 그렇지 않으면 if 조건이 성립하지 않는 경우 값이 정해지지 않는 표현식이 되어 오류가 발생한다.

  • 하스켈의 표현식과 명령문에 대한 보다 자세한 설명은 앞으로 조금씩 이루어질 것이다.

2.3 리스트와 문자열

리스트는 하스켈에서 가장 많이 사용되는 데이터 구조이며 문제를 모델링하고 해결하기 위해 다양한 방법으로 사용될 수 있다. 여기서는 리스트, 문자열, 리스트 조건제시법의 기본 활용법을 살펴본다.

리스트

리스트는 여러 개의 값을 하나로 묶어서 다루는 데이터 구조이다. 리스트에 포함되는 여러 개의 값들 모두 동일한 유형의 값이어야 하며, 서로 쉼표로 구분된 상태에서 대괄호로 감싸인다. 예를 들어, 정수로 이루어진 리스트를 반환하는 함수를 다음과 같이 정의한다.

In [55]:
lostNumbers = [4,8,15,16,23,42]

참고: 대화형 GHC 컴파일러인 GHCi에서 이름을 바로 정의하려면 let 키워드를 사용해야 한다. 반면에 하스켈 소스코드에서는 그럴 필요가 없다. 주피터 노트북에서는 let 키워드 사용은 선택사항이다.

In [56]:
let lostNumbers = [4,8,15,16,23,42]
In [57]:
lostNumbers
[4,8,15,16,23,42]

정수와 실수로 이루어진 리스트 또는 정수와 문자로 이루어진 리스트는 허영되지 않는다. 만약 [1,2,'a',3,'b','c',4]을 시도한다면, 하스켈은 작은 따옴표 사이에 표기된 문자가 숫자가 아니라고 오류을 발생시킨다.

In [58]:
[1,2,'a',3,'b','c',4]
<interactive>:1:2: error:
    • No instance for (Num Char) arising from the literal ‘1’
    • In the expression: 1
      In the expression: [1, 2, 'a', 3, ....]
      In an equation for ‘it’: it = [1, 2, 'a', ....]

참고: 다음 세 개의 리스트는 서로 다른 리스트를 나타낸다.

[], [[]], [[],[],[]]
  • []: 공리스트, 즉 비어있는 리스트
  • [[]]: 공리스트 하나를 항목으로 갖는 리스트
  • [[],[],[]]: 세 개의 공리스트를 항목으로 갖는 리스트

문자열

문자열은 문자로 구성된 리스트이다.

  • 문자 유형(Char): 'a', 'b', 'c', 'X', 'Y', 'Z' 등 작은 따옴표로 감싸는 하나의 기호들의 유형이다.
  • 문자열: "hello", "Haskell" 등 큰 따옴표로 감싸는 문자들의 나열을 가리킨다. 하지만, 예를 들어, "hello"는 단지 ['h','e','l','l','o']를 예쁘게 포장한 것에 불과하다. 따라서 문자열의 유형이 따로 존재하는 것이 아님에 주의해야 한다.
In [59]:
"hello" == ['h','e','l','l','o']
True
In [60]:
"Haskell" == ['H','a','s','k','e','l','l']
True

리스트 관련 함수

리스트를 다루는 기초적인 함수들을 살펴보고자 한다.

이어붙이기(++) 함수

++ 는 리스트 두 개를 이어붙혀서 새로운 리스트를 생성하여 반환하는 중위 함수이다.

In [61]:
[1,2,3,4] ++ [9,10,11,12]
[1,2,3,4,9,10,11,12]

주의사항: ++ 연산자의 첫째 인자로 사용되는 리스트가 많은 항목을 가진 경우 연산이 오래 걸릴 수 있다. 이유는, 예를 들어, [1,2,3] ++ [4]를 계산하기 위해 내부적으로 아래와 같은 과정을 거치기 때문이다. 마지막 줄에 있는 표현식이 바로 [1,2,3,4] 이다.

[1,2,3] ++ [4] => 1 : ([2,3] ++ [4])
               => 1 : 2 : ([3] ++ [4])
               => 1 : 2 : 3 : ([] ++ [4])
               => 1 : 2 : 3 : 4 : []

문자열이 리스트로 정의되었기에 문자열에서 리스트 관련 함수를 사용할 수 있다.

In [62]:
"hello" ++ " " ++ "world"
"hello world"
In [63]:
['G','o'] ++ ['o','d'] ++ ['!']
"Good!"

cons 함수

앞서 언급하였듯이 [1,2,3,4]1 : 2 : 3 : 4 : []를 간단하게 표현한 것에 불과하다. 여기서 (:)콘스(cons) 라고 불리는 연산자이며 하나의 값을 이미 생성된 리스트의 맨 앞에 추가하는 방식으로 새로운 리스트를 생성하는 리스트 생성자 역할을 수행하는 함수이다. 생성자에 대해서는 나중에 자세히 다룬다.

In [64]:
(:) 1 [2,3,4]
[1,2,3,4]

cons 함수는 일반적으로 중위 함수로 사용된다.

In [65]:
1 : [2,3,4]
[1,2,3,4]
In [66]:
'A':" SMALL CAT"
"A SMALL CAT"

인덱싱(!!) 함수

리스트의 각 항목은 왼편으로부터 0, 1, 2, 등으로 시작하는 위치정보를 가지며, 그 위치정보를 각 항목의 인덱스(index) 라 부른다. 인덱스가 주어졌을 때 해당 인덱스가 가리키는 항목을 확인하는 작업이 인덱싱(indexing)이며, 인덱싱 함수를 (!!)로 표기한다.

In [67]:
"Steve Buscemi" !! 6
'B'
In [68]:
[9.4,33.2,96.2,11.2,23.25] !! 1
33.2

주의사항: 인덱스는 0부터 시작한다. 즉, 맨 왼편에 위치한 항목의 인덱스가 0이며, 그 오른편의 항목의 인덱스는 1이다. 따라서 마지막 항목의 인덱스는 항목의 수에서 1을 뺀 값이다.

In [69]:
[9.4,33.2,96.2,11.2,23.25] !! 0
9.4
In [70]:
[9.4,33.2,96.2,11.2,23.25] !! (5-1)
23.25

인덱싱을 리스트에 포함된 항목 개수보다 같거나 큰 인덱스에 대해 적용하면 오류가 발생한다.

In [71]:
[9.4,33.2,96.2,11.2,23.25] !! 5
Prelude.!!: index too large

리스트의 항목은 하스켈이 지원하는 임의의 유형의 값을 가질 수 있다. 예를 들어, 리스트로 구성된 리스트를 작성할 수 있다. 여기서 주의할 점은 리스트의 길이가 다르다고 해서 다른 유형의 값이 되는 것은 아니라는 사실이다.

In [72]:
let b = [[1,2,3,4],[1,2,2,3,4],[1,2,3]]

b
[[1,2,3,4],[1,2,2,3,4],[1,2,3]]
In [73]:
b ++ [[1,1,1,1,1,1]]
[[1,2,3,4],[1,2,2,3,4],[1,2,3],[1,1,1,1,1,1]]
In [74]:
[6,6] : b
[[6,6],[1,2,3,4],[1,2,2,3,4],[1,2,3]]
In [75]:
b !! 2
[1,2,3]

리스트의 항목으로 사용되는 리스트는 서로 길이가 다를 수는 있지만 유형이 다를 수는 없다.

  • [1,2]: 정수 리스트
  • ['a','b']: 문자 리스트, 즉, 문자열
In [76]:
[[1,2], ['a','b']]
<interactive>:1:3: error:
    • No instance for (Num Char) arising from the literal ‘1’
    • In the expression: 1
      In the expression: [1, 2]
      In the expression: [[1, 2], ['a', 'b']]

리스트에 포함된 항목들을 서로 비교할 수 있다면 리스트 사이의 사전식 비교가 가능해짐.

  • 비교 연산자: <, <=, >, >=

  • 사전식 비교: 두 리스트의 첫째 항목끼리 비교하고 같을 경우 둘째 항목, 같은 경우 셋째 항목 등등으로 비교

In [77]:
[3,2,1] >= [2,1,0]
True
In [78]:
[2,10,100] < [3]
True

리스트 중에서 공리스트(empty list)가 크기가 가장 작다.

In [79]:
[] < [0]
True

이것이 아래 부등식이 성립하는 이유이다.

In [80]:
[3,4,2] > [3,4]
True

두 리스트의 모든 항목이 동치(equal)이면 두 리스트 또한 동치이다.

In [81]:
[3,4,2] == [3*1,2+2,8/4]
True

참고: 린팅 기능을 다시 켜면 아래와 3 * 1 대신에 3을 사용할 것을 권장한다.

In [82]:
:opt lint
In [83]:
[3,4,2] == [3*1,2+2,8/4]
Evaluate
Found:
3 * 1
Why Not:
3
True

위와 같은 추천이 굳이 필요하지 않기에 앞으로 특별한 경우가 아니면 린팅 기능을 사용하지 않는다.

In [84]:
:opt no-lint

head 함수

리스트의 머리(첫째 항목)를 반환한다.

In [85]:
head [5,4,3,2,1]
5

tail 함수

리스트의 머리를 제외한 나머지 항목으로 이루어진 리스트를 반환환다.

In [86]:
tail [5,4,3,2,1]
[4,3,2,1]

last 함수

리스트의 가장 오른편에 위치한, 즉, 가장 마지막 항목을 반환한다.

In [87]:
last [5,4,3,2,1]
1

init 함수

리스트의 마지막 항목을 제외한 항목으로 이루어진 리스트를 반환한다.

In [88]:
init [5,4,3,2,1]
[5,4,3,2]

head, tail, last, init 네 함수의 기능을 그림으로 표현해보면 다음과 같다.

주의사항: 공리스트에 대해 앞서 소개한 네 개의 함수 모두 오류를 발생시킨다. 이유는 네 함수 모두 첫째 항목 또는 마지막 항목의 존재를 요구하기 때문이다. 이런 오류는 컴파일 과정에서 발각되지 않기에 위 함수들을 사용할 때 오류발생에 대한 예방조치를 취해야 한다. 이후에 error 함수와 Maybe 유형을 활용하여 간단한 방식으로 오류를 예방하는 방식을 다룰 것이다.

In [89]:
head []
Prelude.head: empty list
In [90]:
tail []
Prelude.tail: empty list
In [91]:
last []
Prelude.last: empty list
In [92]:
init []
Prelude.init: empty list

length 함수

리스트의 길이, 즉, 리스트에 포함된 항목의 수를 반환한다.

In [93]:
length [5,4,3,2,1]
5
In [94]:
length []
0

null 함수

리스트가 비어있는지 여부를 확인한다. 즉, 공리스트이면 True, 그렇지 않으면 False를 반환한다.

In [95]:
null [1,2,3]
False
In [96]:
null []
True

리스트 이름을 xs라 할 때, xs == [] 와 동일한 값을 반환한다.

In [97]:
null [1,2,3] == ([1,2,3] == [])
True
In [98]:
null [] == ([] == [])
True

참고: 전위 함수의 우선순위가 가장 높기 때문에, 위 두 표현식의 의미는 다음과 같다.

In [99]:
(null [1,2,3]) == ([1,2,3] == [])
True
In [100]:
(null []) == ([] == [])
True

reverse 함수

주어진 리스트에 포함된 항목들의 순서를 거꾸로 해서 갖는 리스트를 반환한다.

In [101]:
reverse [5,4,3,2,1]
[1,2,3,4,5]

take 함수

리스트에서 지정한 수만큼의 항목만을 선택하여 만든 리스트를 반환한다. 단, 항목은 0번 인덱스부터 지정한 수만큼 차례대로 선택한다.

In [102]:
take 3 [5,4,3,2,1]
[5,4,3]
In [103]:
take 1 [3,9,3]
[3]

지정한 수가 리스트의 길이보다 크면 리스트 전체를 반환한다.

In [104]:
take 5 [1,2]
[1,2]

0개를 선택하라 하면 공리스트를 반환한다.

In [105]:
take 0 [6,6,6]
[]

drop 함수

take 함수와는 달리 지정한 수만큼의 항목을 제외한 나머지 항목들로 이루어진 리스트를 반환한다. 제외되는 항목은 0번 인덱스부터 지정한 수만큼 차례대로 선택된다.

In [106]:
drop 3 [8,4,2,1,5,6]
[1,5,6]
In [107]:
drop 0 [1,2,3,4]
[1,2,3,4]
In [108]:
drop 100 [1,2,3,4]
[]

maximum / minimum 함수

지정된 순서에 따라 크기를 비교할 수 있는 값으로 이루어진 리스트에서 가장 큰/작은 항목을 반환한다.

In [109]:
minimum [8,4,2,1,5,6]
1
In [110]:
maximum [1,9,2,3,4]
9

sum / product 함수

리스트에 포함된 항목들의 합/곱을 반환한다.

In [111]:
sum [5,2,1,6,3,2,5,7]
31
In [112]:
product [6,2,1,2]
24
In [113]:
product [1,2,5,6,7,9,2,0]
0

elem 함수

리스트의 항목으로 포함되었는지 여부를 확인해준다. 원래 전위 함수이지만 중위 함수로 사용하면 함수의 기능을 보다 쉽게 이해할 수 있다.

In [114]:
4 `elem` [3,4,5,6]
True
In [115]:
10 `elem` [3,4,5,6]
False

지금까지 리스트와 관련된 기초 함수들을 살펴보았다. 보다 다양한 함수는 나중에 리스트 관련 모듈을 소개할 때 좀 더 다룰 것이다.

레인지(Range)

하스켈에서 제공하는 레인지(range) 기능을 이용하여 특정 구간 내에서 특정 규칙을 따르는 값들로 이루어진 리스트를 간단하게 정의할 수 있다. 단, 리스트의 항목에 사용되는 값들은 정수, 문자 등 열거될 수 있는(enumerable) 특성을 가져야 한다.

  • 정수: 1, 2, 3, 4 ...
  • 문자: A, B, C, ..., Z, a, b, c, ... z 등등

아래 리스트는 1부터 20까지의 자연수를 모두 포함하는 리스트를 레인지로 정의한다.

In [116]:
[1..20]
[1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20]

a부터 z까지의 모든 소문자 알파벳으로 구성된 리스트는 다음과 같다.

In [117]:
['a'..'z']
"abcdefghijklmnopqrstuvwxyz"

K부터 Z까지 모든 대문자 알파벳으로 구성된 리스트는 다음과 같다.

In [118]:
['K'..'Z']
"KLMNOPQRSTUVWXYZ"

주의사항: 문자열은 열거 기능이 없다. 예를 들어, "Haskell" 다음에 어떤 문자열이 위치하는지 정할 수 없다.

보폭(steps)

예를 들어, 1부터 20 까지의 수 중에서 짝수만으로 이루어진 리스트를 원한다면 아래와 같이 보폭을 2로 하는 레인지로 정의하면 된다. 보폭은 첫 두 항목의 차이로 지정한다.

In [119]:
[2,4..20]
[2,4,6,8,10,12,14,16,18,20]

3부터 20 사이의 3의 배수로 이루어진 레인지는 다음과 같다.

In [120]:
[3,6..20]
[3,6,9,12,15,18]

20부터 1까지의 숫자를 가진 리스트를 생성하려면 아래와 같이 해야한다.

In [121]:
[20,19..1]
[20,19,18,17,16,15,14,13,12,11,10,9,8,7,6,5,4,3,2,1]

보폭을 지정하지 않으면 의도치 않은 결과가 나올 수 있다.

In [122]:
[20..1]
[]

또한 아래처럼 보폭이 달라지는 리스트를 레인지로 정의할 수 없다.

In [123]:
[1,2,4,8,16...100]
<interactive>:1:12: error: Variable not in scope: (...) :: Integer -> Integer -> a

주의사항: 부동소수점을 레인지와 함께 사용할 때는 많은 주의를 기울여야 한다. 이유는 컴퓨터 자체가 부동소수점을 엄밀하게 다루지 못하기 때문이며, 따라서 부동소수점을 레인지와 함께 사용하는 것은 기본적으로 권장되지 않는다.

In [124]:
[0.1, 0.3 .. 1]
[0.1,0.3,0.5,0.7,0.8999999999999999,1.0999999999999999]

2.4 무한 리스트, 지연성, 조건제시법

무한 리스트

레인지를 활용할 때 상한을 지정하지 않으면 길이가 무한인 리스트(infinite list)가 정의된다. 예를 들어, 아래 레인지는 0, 1, 2, ... 등 모든 자연수를 항목으로 갖는 무한 리스트를 생성한다.

[0..]

그런데 무한 리스트는 당연히 조심해서 다루어야 한다. 그렇지 않으면 무한반복 실행이 발생할 수 있는데, 위 정의를 직접 실행하는 일은 피해야 한다.

지연성

하스켈은 리스트의 항목을 정말로 활용해야 할 때까지 구체적으로 확인하지 않는다. 이와같은 계산방식이 지연 계산(lazy evaluation)이며, 이런 방식을 지원하는 프로그래밍언어의 지연성(laziness)이라 부른다. 하스켈의 지연성(laziness)을 적절하게 활용하면 무한 리스트의 효용성을 극대화할 수 있는데, 이는 하스켈만의 매우 독특한 특성이다.

예를 들어, 13단에서 13 * 1 부터 13 * 9까지의 값을 다음처럼 생성할 수 있는데 이유는 take 함수가 요구하는 첫 9개의 항목을 확인한 다음에 계산을 먼추기 때문이다.

In [125]:
take 9 [13,26..]
[13,26,39,52,65,78,91,104,117]

아래와 같이 할 수도 있지만 위 방식이 보다 다루기 쉽다.

In [126]:
[13,26..13*9]
[13,26,39,52,65,78,91,104,117]

참고: 적극 계산(eager evaluation)

  • 지연 계산에 대비되는 개념이다. 예를 들어, take 9 [13, 26...]을 적극 계산 방식으로 계산하려 하면 계산이 멈추지 않을 것이다. 이유는 take 함수가 먼저 두 개의 인자를 정확하게 파악하려 하기에 둘째 인자인 무한 리스트의 모든 항목을 확인하는 과정을 거쳐야 하기 때문이다.
  • C, 자바, 파이썬 등은 기본적으로 적극 계산 방식을 사용한다.

아래 두 함수 모두 무한 리스트를 생성하여 반환한다.

  • cycle: 하나의 리스트를 무한 반복시킨 리스트 생성.

  • repeat: 하나의 값으로 이루어진 무한 리스트 생성.

위 두 함수가 반환한 무한 리스트를 위에서처럼 take 함수를 이용하여 유용하게 활용할 수 있다.

In [127]:
take 10 (cycle [1,2,3])
[1,2,3,1,2,3,1,2,3,1]
In [128]:
take 12 (cycle "LOL ")
"LOL LOL LOL "
In [129]:
take 10 (repeat 5)
[5,5,5,5,5,5,5,5,5,5]

참고: 동일한 항목으로 이루어진 유한(finite) 리스트를 생성하려면 replicate 함수를 사용는 것이 보다 간단하다.

In [130]:
replicate 10 5
[5,5,5,5,5,5,5,5,5,5]

리스트 조건제시법

수학에서 집합을 정의할 때 사용하는 조건제시법 기술을 리스트를 생성할 때 활용할 수 있으며, 레인지보다 훨씬 다양한 리스트를 쉽게 생성할 수 있도록 도와주다.

리스트에 조건제시법을 적용하는 방법은 집합을 정의할 때와 사실상 동일하다. 예를 들어, 1부터 시작해서 10개의 짝수를 포함하는 집합을 조건제시법으로 정의하면 아래와 같다.

$$ \{\, 2 x \mid x \in \mathbb{N}, x \le 10\, \} $$

위 조건제시법에 사용된 기호와 수식의 역할은 다음과 같다.

  • 막대기 모양의 기호 (|, 파이프라 부르기도 함) 왼편에 위치한 \ 표현식 $2 x$ 는 집합에 포함될 원소의 값을 표현하는 수식
  • $x$ 는 원소의 값을 표현하는 수식에 사용되는 변수
  • $\mathbb{N}$ 은 주어진 집합
  • $x \le 10$ 은 집합 원소로 포함될 조건을 담은 수식

위 집합을 하스켈의 리스트로 다음과 같이 정의할 수 있다.

In [131]:
[x*2 | x <- [1..10]]
[2,4,6,8,10,12,14,16,18,20]

주의사항: 형식적으로는 아래와 같이 앞서 정의한 집합과 사실상 동일하게 정의할 수도 있다. 하지만 아래 정의를 실행하면 무한반복에 빠진다. 이유는 모든 양의 정수에 대해 10보다 같거나 작은지 여부를 검사하기 때문이다.

[x*2 | x <- [1..], x <= 10]

앞서 살펴본 대로 하스켈의 지연성과 take 함수를 활용하여 10개의 2의 배수를 구하는 문제는 아래 방식으로도 가능하다.

In [132]:
take 10 [x*2 | x <- [1..], x <= 10]
[2,4,6,8,10,12,14,16,18,20]

물론 아래 방식이 보다 간단하다.

In [133]:
take 10 [x*2 | x <- [1..]]
[2,4,6,8,10,12,14,16,18,20]

방금 살펴본 대로 참(True) 또는 거짓(False)으로 계산될 수 있는 부울 조건식(조건 표현식)을 활용하여 보다 다양한 리스트를 생성할 수 있다. 예를 들어, 1부터 10까지 수를 두 배한 값들 중에서 12 이상인 값들만 항목으로 갖는 리스트를 다음처럼 생성한다.

In [134]:
[x*2 | x <- [1..10], x*2 >= 12]
[12,14,16,18,20]

50부터 100까지의 정수 중에서 7로 나누었을 때 나머지가 3인 정수들로 이루어진 리스트는 mod 함수를 활용하면 된다.

In [135]:
[ x | x <- [50..100], x `mod` 7 == 3]
[52,59,66,73,80,87,94]

필터링

조건식을 사용해 리스트의 항목을 걸러내는 작업을 필터링(filtering) 이라 부른다. 여러 개의 조건식이 사용되면 언급된 모든 조건식이 참이 되어야 한다. 예를 들어, 0~20까지의 모든 정수 중에서 13, 15, 19를 제외한 정수만을 담은 리스트는 다음과 같다.

In [136]:
[ x | x <- [10..20], x /= 13, x /= 15, x /= 19]
[10,11,12,14,16,17,18,20]

리스트에 포함된 항목들을 대상으로 모든 짝수를 제거하면서 동시에 10보다 큰 홀수는 "BANG!"으로, 10보다 작은 홀수는 "BOOM!"으로 변경해주는 함수 boomBangs을 아래처럼 정의할 수 있다.

In [137]:
boomBangs xs = [ if x < 10 then "BOOM!" else "BANG!" | x <- xs, odd x]

위 함수의 본체에 사용된 표현식과 함수는 다음과 같다.

  • if 표현식. 명령문 아니기에 가능함의 유의할 것.
  • odd 함수: 홀수이면 참, 아니면 거짓 반환

7부터 13까지의 정수를 담은 리스트에 대한 결과는 다음과 같다.

In [138]:
boomBangs [7..13]
["BOOM!","BOOM!","BANG!","BANG!"]

다변수 조건제시법

파이프(|) 왼편에 사용되는 항목 표현식에 여러 개의 변수가 활용될 수 있다. 이런 경우 파이프 오른편에 각각의 변수에 대한 조건식이 포함되어야 한다.

예를 들어, 아래 조건제시법은 12개의 항목을 갖는 리스트를 생성한다.

In [139]:
[ x*y | x <- [2,5,10], y <- [8,10,11, 15]]
[16,20,22,30,40,50,55,75,80,100,110,150]

여기서 12개의 항목이 만들어지는 순서는 다음과 같다.

  • 먼저 x가 2일 때, y값을 8, 10, 11, 15에 대해 곱한 값들이 맨 처음 네 자리를 차지함.
  • 다음은 x가 5일 때, y값을 8, 10, 11, 15에 대해 곱한 값들이 그 다음 네 자리를 차지함.
  • 끝으로 x가 10일 때, y값을 8, 10, 11, 15에 대해 곱한 값들이 마지막 네 자리를 차지함.

만약 곱셈의 결과가 50 이상인 경우만 알고 싶다면 아래 코드처럼 작성하면 된다.

In [140]:
[ x*y | x <- [2,5,10], y <- [8,10,11,15], x*y > 50]
[55,75,80,100,110,150]

형용사를 포함한 리스트와 명사를 포함한 리스트를 결합한 리스트는 다음과 같다.

In [141]:
let nouns = ["hobo","frog","pope"]
let adjectives = ["lazy","grouchy","scheming"]
[adjective ++ " " ++ noun | adjective <- adjectives, noun <- nouns]
["lazy hobo","lazy frog","lazy pope","grouchy hobo","grouchy frog","grouchy pope","scheming hobo","scheming frog","scheming pope"]

밑줄(와일드카드) 활용

조건제시법에 사용되는 변수의 이름이 중요하지 않은 경우 밑줄(_)을 활용한다. 밑줄(_)은 이름으로 사용되지 못하기 때문에 파이프 왼편 수식에서 사용될 수 없다.

예를 들어, 아래 정의에서 리스트의 항목은 리스트 xs 항목을 가리키는 변수와 상관없이 무조건 숫자 1로 지정되며, 1이 모두 xs 길이만큼 포함된다.

In [142]:
length' xs = sum [1 | _ <- xs]

밑줄을 영어로 언더바(underbar)라고 부르는 경우가 있는데 옳지 않은 표현이다. 밑줄의 영어 표현은 언더스코어(underscore)이다. 또한 밑줄 처럼 임의의 값을 대신에서 사용되는 기호를 와일드카드(wild card)라고 부르기도 한다.

문자열과 조건제시법

문자열도 리스트이기에, 문자열을 처리하고 생성하는데 리스트 조건제시법을 사용할 수 있다. 아래 함수는 문자열에 포함된 문자 중에서 대문자 알파벳만 선택해서 새로운 리스트를 생성한다.

In [143]:
removeNonUppercase str = [ c | c <- str, c `elem` ['A'..'Z']]
In [144]:
removeNonUppercase "Hahaha! Ahahaha!"
"HA"
In [145]:
removeNonUppercase "IdontLIKEFROGS"
"ILIKEFROGS"

중첩 조건제시법

중첩 리스트를 조건제시법과 함께 활용하기 위해 중첩 조건제시법을 사용한다. 아래 예제는 정수들의 리스트로 이루어진 이중 리스트에서 홀수를 제거하는 방법을 보여준다.

In [146]:
let xxs = [[1,3,5,2,3,1,2,4,5],[1,2,3,4,5,6,7],[1,2,4,2,1,6,3,1,3,2,3,6]]

[ [ x | x <- xs, even x ] | xs <- xxs]
[[2,2,4],[2,4,6],[2,4,2,6,2,6]]

먼저 xs를 기준으로 삼는다. 즉, 바깥쪽에 위치한 파이프의 오른편에 사용된 변수가 먼저 사용된다. 이후 xs에 포함된 항목들 중에서 짝수만 선택하는 조건제시법이 사용되었다.

참고: 리스트 조건제시법을 여러 줄에 거쳐 작성할 수 있다. 특히 중첩된 경우와 같이 리스트 조건제시법이 길어질 경우에는 여러 줄로 나누어서 작성하는 것이 권장된다. 예를 들어, 아래 조건제시법은 xxs에서 길이가 8보다 큰 리스트에 대해서만 선택 과정이 작동한다.

In [147]:
[ [ x | x <- xs, even x ] | xs <- xxs,
                            length xs > 8 ]
[[2,2,4],[2,4,2,6,2,6]]

2.5 튜플(Tuple)

튜플 또한 여러 개의 값을 묶어 하나의 값으로 다루는 기능을 제공한다. 튜플과 리스트의 차이점은 다음과 같다.

리스트 튜플
대괄호 사용 소괄호 사용
포함된 항목의 유형이 동일 서로 다른 유형의 항목 포함 가능
유형이 리스트의 길이와 무관 유형이 길이에 의존
무한 리스트 가능 유한 튜플만 가능
길이가 0 또는 1인 리스트 가능 길이가 2 이상

예제: 2차원 벡터

2차원 벡터를 리스트를 이용하여 다음과 같이 정의할 수 있다.

In [148]:
[[1,2],[8,11],[4,5]]
[[1,2],[8,11],[4,5]]

하지만 위 값의 유형은 다음과 같은 리스트도 포함한다. 이유는 리스트의 유형은 길이와 무관하기 때문이다.

In [149]:
[[1,2],[8,11,5],[4,5]]
[[1,2],[8,11,5],[4,5]]

튜플을 이용하여 이런 단점을 해결할 수 있다. 이유는 튜플의 유형은 튜플의 길이에도 의존하기 때문이다. 예를 들어,

(1,2)

(8,11,5)

는 서로 다른 유형의 튜플이다. 따라서 아래와 같은 튜플 리스트는 허용되지 않는다.

In [150]:
[(1,2),(8,11,5),(4,5)]
<interactive>:1:8: error:
    • Couldn't match expected type ‘(a, b)’ with actual type ‘(Integer, Integer, Integer)’
    • In the expression: (8, 11, 5)
      In the expression: [(1, 2), (8, 11, 5), (4, 5)]
      In an equation for ‘it’: it = [(1, 2), (8, 11, 5), (4, 5)]
    • Relevant bindings include it :: [(a, b)] (bound at <interactive>:1:1)

또한 [(1,2),("One",2)]와 같은 리스트도 만들 수 없다. 이유는 리스트의 첫 번째 항목은 숫자로 구성된 튜플이지만, 두 번째 항목은 문자열과 숫자로 구성된 튜플이라서 두 튜플의 유형이 다르기 때문이다.

튜플의 특징 요약

  • 임의의 유형의 값이 튜플의 항목으로 사용될 수 있다. 위 예제에서 보았듯이, 예를 들어, 리스트도 튜플의 항목으로 사용될 수 있다.
In [151]:
(1,'a',[2,3],[['c','d'],['e','f','g']])
(1,'a',[2,3],["cd","efg"])
  • 튜플의 길이는 임의로 길어도 된다. 단, 무한 튜플은 허용되지 않는다.

  • 데이터에 포함되어야 하는 원소의 수를 미리 알고 있는 경우 튜플 사용을 권장한다.

  • 리스트와 달리 길이를 달리할 수 없기 때문에 보다 안정적으로 데이터를 저장할 수 있다. 즉, 데이터를 임의적으로 추가하거나 삭제할 수 없다.

  • 튜플의 길이는 2 이상이어야 한다.

  • 길이가 1인 튜플은 존재하지 않는다. 하나의 값을 소괄호로 감싸면 단순히 값을 구분하기 위한 용도로 인식된다.
In [152]:
'a' == ('a')
True
  • 항목을 전혀 갖지 않은 튜플도 없다.

주의사항: ()는 하스켈에서 단 하나의 값으로만 구성된 유닛(unit) 유형 또는 그 유형의 유일한 값을 가리킨다. 유닛 유형은 나중에 다시 살펴볼 것이다.

  • 튜플의 항목에 대해 동치성/크기 비교를 할 수 있으면 튜플 자체도 동치성/크기 비교가 가능하다.
In [153]:
(1,2,3) <= (5,6,7)
True
In [154]:
(5,7) == (3+2,8-1)
True
  • 길이가 다른 커플은 비교 불가능이다. 이유는 서로 유형이 다르기 때문이다.
In [155]:
(1,2) <= (5,6,7)
<interactive>:1:10: error:
    • Couldn't match expected type ‘(Integer, Integer)’ with actual type ‘(Integer, Integer, Integer)’
    • In the second argument of ‘(<=)’, namely ‘(5, 6, 7)’
      In the expression: (1, 2) <= (5, 6, 7)
      In an equation for ‘it’: it = (1, 2) <= (5, 6, 7)

튜플 관련 함수

튜플을 활용하는 함수 세 개를 소개한다.

fst 함수

길이가 2인 튜플의 첫째 항목을 반환한다.

In [156]:
fst (8,11)
8
In [157]:
fst ("Wow", False)
"Wow"

snd 함수

길이가 2인 튜플의 둘째 항목을 반환한다.

In [158]:
snd (8,11)
11
In [159]:
snd ("Wow", False)
False

참고: fst 함수와 snd 함수는 길이가 3 이상인 튜플에는 적용할 수 없다. 나중에 임의의 길이의 튜플에서 항목을 선택하는 방법에 대해 알아볼 것이다.

zip 함수

두 개의 리스트가 주어졌을 때 각 리스트의 항목으로 구성된 길이가 2인 튜플들의 리스트를 반환한다.

각 튜플의 항목은 각 리스트에서 동일한 인덱스를 가져야 한다. 즉, 첫째 항목끼리, 둘째 항목 끼리 등등으로 튜플을 생성하여 리스트에 포함시킨다. 두 개의 리스트를 특정 방식으로 결합하거나 두 리스트의 항목을 동시에 차례대로 확인할 때 매우 유용하다.

In [160]:
zip [1,2,3,4,5] [5,5,5,5,5]
[(1,5),(2,5),(3,5),(4,5),(5,5)]

사용되는 두 리스트의 유형과 상관없이 작동한다.

In [161]:
zip [1 .. 5] ["one", "two", "three", "four", "five"]
[(1,"one"),(2,"two"),(3,"three"),(4,"four"),(5,"five")]

두 리스트의 길이가 다르면 짧은 길이에 맞추어 순서쌍을 생성한다.

In [162]:
zip [5,3,2,6,2,7,2,5,4,6,6] ["im","a","turtle"]
[(5,"im"),(3,"a"),(2,"turtle")]

즉, 보다 긴 리스트는 짧은 리스트의 길이에 맞춰 잘려버려진다. 따라서 하스켈의 지연성을 이횽하면 무한 리스트와 유한 리스트를 zip으로 압축할 수 있다.

In [163]:
zip [1..] ["apple", "orange", "cherry", "mango"]
[(1,"apple"),(2,"orange"),(3,"cherry"),(4,"mango")]

예제: 직각삼각형의 둘레

각 변의 길의가 정수인 직각삼각형 중에서 세 변의 길이의 합이 24인 직각삼각형을 찾아보자. 단, 모든 변의 길이는 10 이하이다.

조건제시법을 활용할 수 있다. 이어지는 설명은 사용되는 조건식을 추가하는 방식으로 조건제시법을 이용한다.

먼저 변의 길이가 10보다 작거나 같은 삼각형의 세 변으로 구성된 튜플들의 리스트를 조건제시법으로 아래와 같이 정의하자.

In [164]:
let triangles = [ (a,b,c) | c <- [1..10], b <- [1..10], a <- [1..10] ]

주의: 위 리스트의 길이는 1,000임. 따라서 리스트를 직접 확인하는 일은 부적절함.

이제 피타고라스 정리를 이용하여 직각삼각형의 세 변만 포함하도록 수정할 수 있다. 또한, 빗변의 길이가 다른 두 변의 길이보다 커야 하는 조건도 자연스럽게 포함시킬 수 있다.

In [165]:
let rightTriangles = [ (a,b,c) | c <- [1..10], b <- [1..c], a <- [1..b], 
                        a^2 + b^2 == c^2 ]

마지막으로 삼각형의 둘레가 24라는 조건을 추가하면 된다.

In [166]:
let rightTriangles' = [ (a,b,c) | c <- [1..10], b <- [1..c], a <- [1..b], 
                        a^2 + b^2 == c^2, 
                        a+b+c == 24 ]

결과는 다음과 같다.

In [167]:
rightTriangles'
[(6,8,10)]

즉, 위 조건을 만족하는 직각삼각형은 하나만 존재한다는 사실을 확인하였다.

참고: 위 문제를 풀어가는 방식이 함수형 프로그래밍에서 일반적으로 사용되는 문제풀이 패턴이다. 즉, 좀 더 일반적인 해를 설정한 후에 필터링을 원하는 답을 구할 때까지 반복 적용한다.