파이썬은 처음이라 – 연속열은 처음이라

이번 시간에는 지난 번에 살짝 언급만 하고 넘어갔던 튜플에 대해서 기본적인 내용을 다루겠다. 튜플은 파고 들자면 제법 묵직해질 수 있는 토픽이기는 하지만, 튜플을 활용하는 화려한(?) 기법들은 개인적으로 중급 이상의 과정에 어울린다고 생각하기 때문에 여기서는 간단한 개념과 기본적인 사용법에 대해서만 설명하고자 한다. 튜플은 여러가지 측면에서 리스트와 비슷한 점이 많고, 실제로 리스트와 크게 구분없이 쓰이는 경향이 있는 것도 사실이다. 리스트와 튜플의 공통적인 특성과 이런 특성을 가지는 타입들을 부르는 말인 연속열에 대해서 이야기해보고자 한다.

튜플은 처음이라

튜플은 여러 개의 값을 묶어서 마치 하나의 값처럼 다룰 수 있는 데이터 형식이다. 리스트와 마찬가지로 원소가 될 수 있는 값의 유형에는 제한이 없으며, 각각의 원소는 0부터 시작하는 정수 인덱스로 참조할 수 있다. 튜플은 소괄호((  )) 로 둘러싸인 콤마로 구분된 값으로 정의되는 것이 정석이다. 하지만 몇 가지 예외적인 방법으로 정의하는 것도 가능하다.

a = (1, "A")  # 튜플 a는 정수 1과 문자열 "A"의 쌍으로 묶여 있다. 

## 바인딩 구문의 우변에 위치하는 경우 컴마를 생략할 수 있다.
b = 2, True, None  

이렇게 만들어진 튜플의 각 원소는 리스트와 같은 방식으로 원소 및 부분열을 참조할 수 있다.

a = (1, 2, 3, 4, 5)
a[1] ## -> 2
a[::2] ## -> (1, 3, 5)

리스트와 튜플의 가장 큰 차이점은 리스트는 가변적인 값의 집합인 반면, 튜플은 하나로 단단히 묶여진 값의 순서쌍이며 개별 원소를 추가/삭제/변형하는 것은 허용되지 않는다는 것이다.

a[1] = 20
## TypeError : 'tuple' object does not support item assignment

그러한 특성을 제외하면 튜플은 리스트와 거의 똑같이 사용할 수 있다. 튜플은 FOR 구문을 통해서 개별 원소를 순회할 수 있고, 리스트 축약의 베이스로도 사용될 수 있다.

튜플 분해하기

사실 튜플은 바인딩시에 분해하는 것이 가능하다. 바인딩 구문의 좌변에도 튜플 문법을 쓸 수 있고, 이는 우변에 있는 튜플의 각 원소에 매칭된다. 예를 들어 아래와 같은 식으로 특정한 튜플의 구성요소는 정수 인덱스가 아니라 각각의 위치에 맞는 값으로 분해된다.

a = (1, 2, 3)
x, y, z = a
## x->1, y->2, z->3

## 위 문법은 아래의 동작과 일치
x = a[0]
y = a[1]
z = a[2]

## 사실, 좌변이 튜플일 때, 우변은 리스트여도 됩니다.
x, y, *z = [1, 2, 3, 4, 5]
## x->1, y->2, z->[3,4,5]

이 문법은 튜플을 조금 더 고급지게 다룰 때 다시 이야기할테니 눈여겨봐두고 다음으로 넘어가자.

연속열은 처음이라

튜플과 리스트는 튜플에서는 원소를 추가/변경할 수 없다는 사실을 제외하면 이 두 타입의 행동은 매우 비슷하고, 실제로 대부분의 파이썬 코드에서 튜플과 리스트는 서로 혼용해서 쓸 수 있다. 이것이 가능한 이유는 튜플과 리스트의 구조가 매우 닮아있기 때문인데, 바로 각각의 원소가 자신의 순서를 가진채로 줄을 지어 있는 연속열이기 때문이다. 파이썬에서는 리스트와 튜플외에도 이러한 연속열이  또 하나 있는데, 바로 문자열이다.

파이썬에서는 이러한 연속열이라고 묶을 수 있는 타입들을 실제로 연속열(Sequence)이라고 부르면서 거의 같은 것으로 취급한다. 세 연속열의 차이점은 이러하다.

  • 리스트는 어떤 값이든 원소로 가질 수 있으며, 변경이 가능하다.
  • 튜플은 어떤 값이든 원소로 가질 수 있으나, 변경이 불가능하다.
  • 문자열은 낱개의 문자만을 원소로 가질 수 있으며, 변경은 불가능하다.

이러한 차이를 제외하고, 즉 안에 들어있는 낱개의 원소 타입에 특화된 연산을 하거나, 연속열 자체를 변경하려는 시도를 하지 않는다면 리스트와 튜플 그리고 문자열은 마치 같은 타입처럼 행동할 수 있다. 즉 리스트 축약의 베이스이거나 FOR 문에서 순회할 집합으로 사용할 수 있다.

## 리스트를 이용한 리스트 축약
[ x * 2 for x in [1,2,3]]
#=> [2,4,6]

## 튜플을 그대로 사용할 수 있다.
[x*2 for x in (1, 2, 3)]
#=> [2,4,6]

## 문자열을 써도 된다. 단, (* 2)한 결과는 다르다.
[x*2 for x in "123"]
#-> ["11", "22", "33"]

연속열들의 공통적인 특징은 다음과 같은 것들이 있다.

  • + 연산자를 사용하면 두 연속열을 붙일 수 있다. (이 때 두 연속열을 같은 타입이어야 한다.)
  • * 연산자로 정수를 곱해서 연속열을 반복, 확장할 수 있다.
  • in 연산자를 사용한 멤버십 테스트가 가능하다.
  • seq[i] 와 같이 정수 인덱스를 통해서 개별 원소를 액세스할 수 있다. 인덱스가 음수인 경우에 뒤쪽에서부터 액세스한다.
  • seq[start:end], seq[start:end:step]의 문법으로 슬라이싱할 수 있다.
  • 바인딩 구문에 사용될 때, 좌변에서 튜플 문법을 써서 분해하는 것이 가능하다.

반복가능한 건 또 처음이라

연속열들은 공통적으로 FOR구문에 사용될 수 있다는 특징을 가지고 있다. (그리고 동시에 축약(comprehension)구문에도 사용할 수 있다.) 파이썬에는 비록 연속열은 아니지만 반복가능한 몇 가지 타입들이 더 존재한다. 아직 자세히 살펴보지 않은 그룹형식인 사전과 세트(set)가 그러하다. 그 외에도 지금까지 자주 등장했던 녀석이 하나 있는데, FOR문에서 예시로 많이 쓰인 range() 함수가 그러하다.

range()함수의 도움말을 읽어보면1 다음과 같은 내용을 확인할 수 있다. (아래 예시는 iPython을 이용해서 확인한 내용이다.)

In [1]: range?
Init signature: range(self, /, *args, **kwargs)
Docstring:
range(stop) -> range object
range(start, stop[, step]) -> range object

Return an object that produces a sequence of integers from start (inclusive)
to stop (exclusive) by step.  range(i, j) produces i, i+1, i+2, ..., j-1.
start defaults to 0, and stop is omitted!  range(4) produces 0, 1, 2, 3.
These are exactly the valid indices for a list of 4 elements.
When step is given, it specifies the increment (or decrement).
Type:           type

range()함수는 리스트나 튜플이 아닌 range 객체라는 것을 반환하고, 이 객체는 정수의 연속열을 생성할 수 있는 객체라고 설명하고 있다. 사실 파이썬에서는 이러한 반복가능한 특성을 가진 객체가 은근히 많고, 또 뒤에서 중요하게 다뤄질 제너레이터라는 개념을 배우고 나면 간단하고 편리하게 이러한 반복가능한 값을 만들어서 사용할 수 있다. 이에 대한 자세한 내용은 제너레이터에 대한 이해를 필요로 하니 다음으로 미루기로 하자.

다만 여기에서 반복가능 객체들은 마술 상자 같은 곳에서 정해진 순서에 따라서 값을 뿅뿅 만들어서 하나씩 내놓을 수 있는 능력을 가진 값들이라고 보면 된다. 다만 리스트, 튜플, 문자열은 그 때 그 때 마다 필요한 값을 만드는 것이 아니라, 모든 값을 한 번에 다 만들어서 펼쳐놓고 앞에서부터 하나씩 골라서 쓰는 개념으로 이해하고 있으면 되겠다.

연재 초반에 리스트가 파이썬에서 매우 중요한 타입이라고 언급했었는데 조금씩 그 중요성에 대한 감이 잡히는지 모르겠다. 리스트는 단일 값이 아닌 집합으로서 기능하면서, 연속열과 반복가능이라는 특성으로 이어지면서 FOR문과 그외 다른 유사한 타입들과의 성질을 많이 공유하고 있다. 따라서 이에 대해 이해하면 덤으로 그외 여러 타입들에 대해서 쉽게 친숙해 질 수 있고, 그러한 과정에서 지금은 말로 표현하기 어려운 어떤 언어 자체의 디자인 방향성 같은 것을 체득하게 될 것이다.

그러면 여기에 탄력을 더해서 리스트에 대해서 조금 더 많이 알아보는 시간을 가져야 할 것이다. 그러기 전에 파이썬의 내장 함수 몇 가지를 더 보고 진행해나가도록 하자.


  1. 파이썬 대화형 쉘모드에서나 help(이름)이라고 입력해서 도움말을 볼 수 있다. ipython을 사용한다면 이름? 이라고 입력하는 것으로 더 쉽게 확인이 가능하다. 

(연재) 파이썬은 처음이라 – 리스트는 처음이라

지난 시간에 파이썬의 기본적인 값 타입에 대해서 살펴보았는데, 그 때 소개했던 리스트에 대해서 조금 더 자세히 알아보도록 하겠다. 리스트를 엄밀하게 정의하면 “0개 이상의 값의 원소들의 순서있는 집합“이라고 정의할 수 있다. 즉 일종의 집합(collection)이나 컨테이너(container)의 형태로 그 내부에 여러 개의 값을 원소로 가질 수 있으며 다른 언어에서는 배열(Array) 혹은 벡터(Vector)라 불리는 타입과 비슷하다.

리스트는 순서있는 집합이므로 각 원소는 리스트 내부에서 고유한 순서를 가지고 있는데, 이 순서를 인덱스라고 한다. 인덱스는 0부터 시작하는 정수로, 첫번째 원소가 0번 인덱스를 가지며, 길이가 n 인 리스트의 마지막 원소의 인덱스는 n – 1 이다. 리스트의 원소가 될 수 있는 타입의 종류에는 제한이 없으며, C의 배열과는 달리 서로 다른 타입의 원소들이 같은 리스트에 함께 포함될 수도 있다.

리스트를 만드는 방법

리스트는 수학에서의 ‘집합’과 비슷한 개념의 컨테이너이다. 수학에서의 집합은 두 가지 표기법에 의해서 표현되는데 각각의 원소를 명시해서 표현하는 원소나열법과 집합 내의 각 원소들이 갖추는 조건만을 명시해서 정의하는 조건제시법이 있다. 파이썬에서 리스트를 표현하는 방법도 이와 비슷하게 두 가지가 있다. 하나는 원소 나열법에 대응될 수 있는 리스트 리터럴(list literal)이며, 다른 하나는 조건 제시법에 대응하는 리스트 축약(list comprehension) 문법이다.

리스트 리터럴

리스트 리터럴은 대괄호([   ])속에 각각의 원소가 되는 값이나, 이름을 넣고 각 원소를 컴마로 구분하는 표현이다. 각각의 원소의 타입에는 제한이 없다.

리스트리터럴 ::= [ 원소값표현식,... ]

다음은 리스트 리터럴을 사용해서 정의하는 몇 가지 리스트의 예이다.

## 정수
numbers = [3, 7, 2, 9, 8]
## 문자열
fruits = ['apple', 'banana', 'orange', 'kiwi']
## 불리언
tof = [True, True, False, False, True]
## 혼합 - 여러 타입들을 한 리스트에 같이 담을 수 있다.
aList = [2, 3.14, 'hello', None, True]
## 이중 리스트 - 리스트 역시 '값' 타입이므로 리스트의 원소가 될 수 있다.
nested = [[1,2,3], ['appel', 'orange'], [None, True]]

list 생성함수

지난 시간에 키보드로 정수값을 입력받는 방법을 소개하였는데, 이 때 int() 함수가 언급되었다. 이 함수는 “정수로 바꿀 수 있는 무언가를 정수로 바꿔주는 함수”라고 하였다. 이와 비슷하게 list()라는 함수가 있다. 이 함수는 “리스트로 바꿀 수 있는 무언가”를 리스트로 바꾸어주는 함수이다. 그렇다면 무엇이 리스트로 바꿔질 수 있을까? 바로 “반복가능한” 객체이다. 반복가능한 객체에는 리스트, range 객체1, 문자열2, 튜플, 집합 등등이 있는데, 이러한 객체를 리스트로 만들 수 있다.

x = list(range(10))
## [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]

y = list("apple")
## ["a", "p", "p", "l", "e"]

리스트 축약

리스트 축약은 반복자(iterator)를 통해서 리스트를 생성하는 것으로 for 문을 한줄로 쓴 것과 비슷하게 표현된다. 패턴은 다음과 같다.

[ 표현식 for 식별자 in 반복자 ] [ 표현식 for 식별자 in 반복자 if 조건식]
  • 식별자 : for 문에서 반복되는 원소의 이름으로 쓰이는 변수
  • 반복자 : 리스트, 문자열, range 객체 등 for 문에 쓸 수 있는 반복가능한 객체
  • 표현식 : for 문의 반복되는 값을 사용한 표현식으로, 매 반복에 대한 리스트의 원소를 만드는 식이다.

리스트 축약을 쓰기 위해서는 각 원소들의 베이스가 될 다른 리스트나 반복자가 필요하다. 여기서 반복자란 지난 강좌중 ‘문법은 처음이라 예제편’에서 소개된 for 문에서 사용되는 반복가능한 값을 의미한다. 반복가능한 값의 각 요소값에 대해서 주어진 식별자로 이름을 붙이고, 그 이름을 사용하는 표현식으로 각 원소를 만든다. 예를 들어 range(10)은 0부터 10개의 정수를 생성할 수 있는 수열이므로, 1에서 10까지의 정수로 구성된 리스트는 range(10)의 각 값에 1을 더해서 만들 수 있다. 따라서 [1, 2, 3, … , 10] 의 리스트는 다음과 같이 리스트 축약으로 표현할 수 있다.

[ x + 1 for x in range(10) ] 
## range(10) -> 베이스가 되는 반복자로 0, 1, 2, ..., 9
## x  -> 베이스가 되는 반복자의 각 원소에 붙이는 이름
## x + 1 -> 각 원소에 대한 표현식
## => [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]

표현식을 어떻게 작성하느냐에 따라서 다양하게 원하는 리스트를 만들 수 있다.  리스트 축약의 중요한 점은 리스트 혹은 리스트에 준하는 어떤 반복가능한 시퀀스의 각 원소를 표현식을 통해서 변형한 새로운 리스트를 만드는 방법이라는 점이다. 즉 리스트 축약은 그 자체로 리스트를 구성하는 문법인 동시에, (앞의 강좌에서 언급한 바와 같이) 리스트나 반복가능한 시퀀스를 하나의 값으로 보고 그 리스트를 다른 리스트로 변화시키는 연산으로도 볼 수 있다.

## 1~10의 제곱수
[ (x + 1)** 2 for x in range(10) ]
#= [1, 4, 9, 16, 25, 36, 49, 64, 81, 100]

## 어떤 리스트를 3으로 나눈 나머지들
[ x % 3 for x in [3, 8, 9, 13, 53, 89]]

## 대문자로된 단어들
[ w.upper() for w in ['apple', 'banana', 'orange']]
#= ['APPLE', 'BANANA', 'ORANGE']

리스트 축약의 두 번째 문법은 일종의 확장 문법인데, 기본 리스트 축약 뒤에 IF 절이 붙어 있는 모양으로 IF 절에는 또 하나의 표현식이 들어간다. 이 조건절의 표현식이 참으로 평가될 때만 해당 원소가 결과에 포함될 수 있다. 즉 IF 절을 이용해서 특정 조건에 맞지 않는 원소를 걸러낼 수 있다. 예를 들어서 20보다 작은 3의 배수를 만들기 위해서는 두 가지 방법이 존재할 수 있다.

  1.  0 < n < 7 의 범위의 자연수 n 에 대해서 n * 3 하는 방법
  2.  0 < n < 20 의 범위에 자연수 n에 대해서 n이 3의 배수인 것만 골라내는 방법

첫번째 방법이 더 간단해 보이지만, 범위를 정확히 산정하기 위해서 다시 계산을 해야한다는 점이 조금 불편하고, 코드상으로 보았을 때 “20보다 작은 3의 배수”를 찾는다는 의도가 덜 명확할 수 있다. (물론 루프를 19번 도는 것보단 6번 도는 것이 더 빠를 수는 있다.) 사실상, 리스트 축약이 조건제시법에 대응하는 방법이라는 점에서 개인적으로는 2번의 방법을 선호하는 편이다.

# 조건 제시법으로 표현하는 20 미만의 3의 배수에 1을 더한 값
{ x | 0 < x < 20, x는 3의 배수 }

# 리스트 축약
[ x for x in range(1, 20) if x % 3 is 0 ]

리스트 축약의 의미

리스트 축약은 베이스가 되는 어떤 리스트 A의 각 원소를 1) 특정한 조건식으로 필터링하고 2) 표현식을 통해서 변형한 값을 원소로 갖는 새로운 리스트를 만들어내는 과정을 수반한다. 앞에서도 말했지만, 이는 결국 리스트 그 자체를 값으로 보고 각 원소에 표현식을 적용하여, “변형된 새로운 리스트를 만드는” 과정이라고도 볼 수 있다. 이 때 사용되는 표현식은 사실 변수를 받아서 평가하는 일종의 “값 변형 장치”로 볼 수 있다. 이 관점에서 다음 상황을 살펴보자.

  1. 어떤 정수 리스트 A가 있다고 하자.
  2. 그리고 어떤 정수 n을 2배 한 후에 3을 더하는 함수 f(n) = n * 2 + 3이 있다고 하자.

함수 f(n)은 정수를 인자로 받아서 정수를 반환하는 함수이다. 우리는 “정수 리스트를 2배 한 후에 3을 더하는” 연산을 어떻게 수행해야 하는지 모른다. 정의된 바가 없기 때문이다.3 사실, 정수 리스트에서 정수를 더하는 연산을 상상하기도 어렵다. 대신에 다음과 같이 함수 f(n)이 리스트 A 속으로 들어가서 A의 모든 원소에 대해 동시에 적용되는 작용에 대해서는 상상해볼 수 있다.

함수 f                   |  리스트 A  |  리스트 B
f(n) = n * 2 + 3 ---->    [ 1,     -|-> [ 3,
                            2,     -|->   7,
                            3]     -|->   10]

어떤 함수를 어떤 컨테이너 속으로 들여보내서, 컨테이너의 각 원소에 동일하게 적용하게 하는 것을 사상(mapping)이라고 한다. 어차피 사상에 대해서는 고등학교 정규 교과에서 다루는 것도 아니고, 우리가 하스켈을 배우는 것도 아니니 자세하게 파고 들 생각은 없지만, 이 개념은 매우 중요하다. 왜냐하면 이전까지 대부분의 프로그래밍 강좌에서 배열이나 리스트는 한 단위의 값이 아니라, “자료 구조”로서 취급되었고, 따라서 여기에 값을 넣고 빼고, 특정위치에 삽입하고 하는 조작이 중요시되었다. 하지만 내가 생각할 때 진정으로 중요한 것은 자료 구조 그자체를 어떻게 조작하느냐가 아니라 입력값과 출력값이 어떤 관계를 가지고 있느냐는 것을 생각하고 그것을 표현식으로 표현하는 방법을 익히는 것이다. 문제를 보는 관점을 조금만 거시적으로 옮겨보면 전체적인 흐름이 보이고, 그것을 간략하게 정리할 수 있게 되면 코드가 단순해진다. 코드가 단순해진다는 것은 그만큼 문제를 단순화해서 풀 수 있다는 것이고 다시, 그만큼 명료하게 사고할 수 있게 된다는 것을 말한다.

리스트의 원소

다시 리스트의 원소로 돌아가보자. 리스트는 원소들의 순서가 있는 집합이라고 했다. 각 원소는 리스트 내에서 몇 번째에 위치한다는 순서를 가지고 있으며, 이를 인덱스라고 한다. 모든 원소는 고유의 인덱스를 가지고 있으니, 인덱스를 이용하면 리스트의 여러 원소 중에서 하나의 원소를 정확하게 가리킬 수 있다. 즉 리스트 내의 특정한 원소를 액세스하기 위해서는 인덱스를 사용하면 된다는 말이다.

리스트의 인덱스는 0부터 시작하는 정수이며, 리스트의 각 원소는 그 인덱스를 i 라 할 때 aList[i]와 같은 식으로 읽을 수 있으며, 쓸 수도 있다. 읽는다는 말은 aList[i] 라는 표현식이 리스트 내의 i 번째 원소로 평가된다는 말이며, 쓴다는 말은 aList[i] = 3 과 같이 리스트의 i 번째 원소를 3이라는 값으로 교체한다는 말이다.

aList = [1, 2, 3, 4]
##       ^  ^  ^  ^ 3번 원소
##       |  |  └ 2번 원소
##       |  └ 1번 원소
##       └ 0번 원소    

a[0] #= 1
a[1] #= 2
a[2] = 30 ## 2번 원소를 30으로 교체했다. 
# A = [1, 2, 30, 4] 
#            ^^ 교체됨

만약 aList[4]와 같이 범위 밖의 인덱스를 참조하려고 하면 IndexError가 발생하면서 프로그램의 실행이 중단된다. 그럼 aList[-1]로 접근하게 되면 이 경우도 범위를 벗어나서 에러가 될 것인가?

음의 인덱스

인덱스가 음수인 경우, 뒤에서부터 몇 번째 원소인지를 센다. 이 때 -1은 리스트의 맨 마지막 원소이며 (-0 은 결국 0이니 리스트의 맨 첫 원소가 될 것이다.) 거꾸로 -2, -3, -4…와 같이 참조할 수 있다. 여기서도 원소가 4개인 aList에 대해서 aList[-5]와 같이 존재하지 않는 인덱스를 참조하려하면 IndexError가 발생한다.

aList = [2,3,4,5]
aList[-1] ## 5
aList[-2] ## 4

슬라이스 – 부분집합에 대한 참조

리스트에 대해서 하나의 원소를 참조하는 것외에 부분집합을 참조해야 하는 경우가 있다. 예를 들어서 0~9의 10개의 정수 리스트를 만들고 첫 5개 원소를 갖는 부분집합과 나중 5개 원소를 갖는 부분집합을 나눠서 참조할 필요가 있을 것이다. 이렇게 리스트로부터 부분열을 참조할 때 사용하는 것이 슬라이스 문법인데, 인덱스를 통한 원소 참조 방법을 약간 확장한 것이다. 4

슬라이스 문법을 정리하면 다음과 같다.

  • 슬라이스는 숫자와 콜론을 구분자로 사용하여 표현한다.
  • 전체형태는 시작:끝:간격 이다.
  • 시작인덱스 위치부터 끝 인덱스까지, 주어진 간격만큼 건너뛴 원소를 선택한다. 이 때 끝 인덱스는 범위에 포함되지 않는다.
  • 간격은 생략할 수 있고, 생략하는 경우 1이다.
  • 끝은 생략할 수 있고 생략하는 경우 리스트의 길이, 즉 마지막 인덱스이다.
  • 끝이 있는 경우 시작을 생략할 수 있고 이 경우 0이 된다.

리스트 aList = [1,2,3,...,10] 이라고 할 때, 다음과 같은 문법으로 부분 집합을 구할 수 있다. 참고로 이 문법의 의미는 앞서 이전 시간에 소개한 range() 함수의 사용방법과 매우 유사하니 같은 맥락에서 이해하면 쉽다. 아래는 몇 가지 각 경우에 대한 예제이다.

  • aList[3] : 3번 인덱스(4번째 원소, 4이다.)에 대한 단일 원소를 참조한다.
  • aList[0:4] : 콜론으로 구분되는 두 개의 숫자가 쓰이면 start:end 의 의미가 되며, 이 때 range(0, 4)와 같이 end 값은 범위에 포함되지 않는다. 따라서 aList[0:4][1, 2, 3, 4]으로 3번 인덱스까지만 포함한다.
  • aList[4:8] : 같은 의미에서 4번, 6번, 7번 인덱스를 포함한다. 즉 [5, 6, 7]에 해당한다.
  • aList[2:10] : aList[10]은 존재하지 않는 인덱스이기 때문에 참조할 수 없다. 하지만 aList[2:10]에서 10은 끝 값이며 실제로는 참조하는 범위에 포함되지 않는다. 따라서 2번 인덱스부터 끝까지라는 의미이며, 에러가 아니다. 같은 의미로 aList[2:12]도 안전하게 동작한다.
  • aList[:5]  : 시작값을 생략해버리면 리스트의 처음부터라는 의미이다. 이는 [1,2,3,4,5] 에 해당한다.
  • aList[5:] : 끝값을 생략해버리면 리스트의 끝까지라는 의미이다. 이는 [6, 7, 8, 9, 10]에 해당한다.
  • aList[0:8:2] : 세 개의 값을 사용하면 이는 start:end:step의 의미이다. aList[0:8:2][1, 3, 5, 7] 을 의미한다. (인덱스로는 0, 2, 4, 6까지 사용되며 stop에 해당하는 8은 역시 포함되지 않는다.)
  • aList[:8:2] : 첫 값을 생략할 수 있으므로 위 표현을 이렇게 쓸 수 있다.
  • aList[5::2] : end 값을 생략할 수 있고, 5번 인덱스부터 한 칸씩 건너뛰면서 선택한다.
  • aList[::2] : 간격이 명시되면 start, end 값은 동시에 생략될 수 있다. [1, 3, 5, 7, 9]를 구하게 된다.
  • aList[:] : step 까지 쓰지 않으면 [:] 으로 참조할 수 있다. 이는 리스트의 처음과 끝까지를 의미하므로 전체 리스트를 의미한다. 이렇게되면 아무 의미없는 문법인 것 같지만, 이것은 리스트 전체에 대한 사본을 얻는 셈이 된다.
  • aList[-1:-5:-1], aList[8:3:-1] : 시작 인덱스가 끝 인덱스보다 뒤에 있고, 간격값이 음수인 경우에는 뒤쪽에서부터 앞으로 원소를 참조한 부분집합이 만들어진다. 즉 step이 음수이면 만들어지는 결과는 뒤에서부터 추출하여 역순이 된다.
  • aList[::-1] : 리스트 전체를 역순으로 참조했다. 즉 전체 리스트를 뒤집은 사본이다.

인덱스에 대한 이해

많은 초보자들이 슬라이싱에 대해서 헷갈려하거나 실수를 많이하게 된다. 이것은 인덱스를 단순히 i 번째 원소를 가리키는 값으로 이해하기 때문에 빚어지는 문제이다. 사실 인덱스는 특정한 원소가 아닌, “그 원소의 바로 앞쪽”을 가리키는 숫자이다. 그림으로 표현하면 아래와 같다.

리스트 A가 [1, 2, 3, 4, 5]라면  A[0]은 실질적으로 “인덱스 0으로부터 1칸의 값”을 의미한다. 따라서 1이 되는 것이다. A[5]는 인덱스 5로부터 1칸을 의미하는데 인덱스 5 뒤에는 값이 없기 때문에 IndexError가 나게 된다.  그림에서 음수 인덱스는 아래쪽에 적어 두었다. 역시 같은 원리로 a[-1]이라고 하면 -1의 위치에서 오른쪽 한 칸인 셈이다.

이 방식으로 인덱스를 이해하면 슬라이스에 대해서도 보다 직관적으로 이해된다. A[2:4]를 구하려면 인덱스 2와 4 사이의 값이다. 위 그림에서 인덱스 2와 4 사이에는 3, 4 가 있다.  또한 2, 3 을 음수 인덱스로 슬라이스하기 위해서는 A[-4:-2]가 되어야 함을 알 수 있다.

자 그렇다면 a[2:2]는 무엇을 의미할까? 한 번 고민해보도록 하자.

리스트의 가변성

지금까지 접해왔던 다른 값 유형과 달리 리스트는 가변적(mutable)이다. 무슨 말인고 하니, 1 이라는 정수값이 있다고 하자. 이 정수값은 다른 값으로 바뀌지 않는다. 아니, 이게 무슨 말이야? 만약 이 글을 읽는 당신이 C를 첫 언어로 시작해서 파이썬으로 건너온 프로그래밍 유경험자라면 동공이 흔들리기 시작할지도 모르겠다. 아래의 코드를 보자.

a = 1
a = a + 1
# a => 2

C에서는 이 코드가 이렇게 해석된다. (물론 문법은 파이썬으로 간주하고…)

  1.  a라는 변수의 메모리에 1이라는 값을 쓴다.
  2. a라는 변수의 메모리에 a의 값(1)에 1을 더한 값을 쓴다. 즉 1이라는 a의 값이 2로 바뀌었다.

그런데 파이썬에서는 그렇지 않다. 기본 타입 중에서 컨테이너가 아닌 모든 타입은 불변하다. 1 + 1 = 2 인데 왜 불변인가? 그것은 파이썬에 1이라는 값에 1을 더한다는 것은, 1 + 1 이라는 표현식을 평가한다는 말이다. 그 결과는 2라는 1하고는 아예 다른 값이다. 그말인 즉슨 1이라는 정수가 그 속에서 뭔가 꿈틀거리다가 2라는 값으로 변신하는 것이 아니라, 1이라는 정수가 있고, 2라는 정수가 그냥 따로 있을 뿐이다. 파이썬에서 위 코드의 올바른 해석은 다음과 같아야 한다.

  1. a라는 이름을 정수 1 에 붙인다.
  2. a + 1 을 평가한다. 이 값은 2라는 다른 정수값이다.
  3. 이 2 라는 정수값에 a라는 이름을 붙인다. 정수 1에는 더 이상 a라는 이름이 붙지 않는다.

그래서 파이썬에는 대입이라는 개념이 없다고 하는 것이다. a에 1이 대입되었다 (라고 설명하면 많은 프로그래밍 교재는 그릇이나 상자에 1이라는 값이 들어가는 것처럼 묘사한다.)라고 생각하면 간단한 수식이나 표현조차 너무 어렵고 이해할 수 없는 것처럼 동작하는 경우가 종종있을 것이다. 어쨌든 중요한 것은 정수라는 타입은 연산을 통해서 다른 정수값이나 실수값을 데려올 수는 있지만, 그 자신이 변신하지는 않는다는 것이다. 그런데 리스트의 경우에는 조금 사정이 다르다. 하나의 리스트는 이름표를 옮기는 변경 없이 리스트 내부의 원소값이 변할 수 있다.

a = [1, 2, 3]
a[0] = 10

리스트의 내용을 변경하는 동작에는 다음과 같은 것이 있을 수 있다.

  1. 특정 인덱스의 값을 다른 값으로 교체
  2. 특정 인덱스의 값을 제거
  3. 리스트의 끝, 혹은 특정한 위치에 인덱스를 삽입
  4. 리스트의 부분집합을 다른 집합으로 변경 (리스트 슬라이스를 다른 리스트로 교체

리스트 연산

리스트에 대한 기본적인 연산은 다음과

  • 원소/부분열 액세스 : aList[3], aList[3:5] 등과 같이 인덱스를 통해서 특정 원소를 읽거나, 변경할 수 있다.
  • 연결 : aList + otherList 와 같이 + 연산자를 통해서 두 개의 리스트를 이어 붙일 수 있다. 반대로 - 연산은 지원하지 않는다.
  • 반복 : aList * 정수의 형태로 주어진 리스트를 여러 차례 반복하는 리스트를 생성할 수 있다. 역시 / 연산은 지원하지 않는다.
  • 멤버십 연산 : e in aList 의 형태로 in 을 원소와 리스트 사이의 연산자로 쓰면 원소 eaList내에 들어있는지를 확인하는 연산이 된다.

리스트를 다룰 때에는 그외의 다른 함수나 리스트의 메소드들을 사용하는 방법들이 더 있지만, 이러한 내용은 별도 토픽으로 다루도록 하겠다.

 


  1. range() 함수를 실행한 결과를 print()해보면 실제로 수열의 내용이 표시되는게 아니라 그냥 range(0, 10) 이라고 표시된다. 
  2. 문자열은 각각 낱개의 글자가 연속적으로 모여있는 집합과 비슷한 개념이다. 
  3. 그런데 재미있게도 파이썬에서는 리스트는 양의 정수와 곱할 수는 있다. 리스트 * n 하면 리스트를 n번 반복한 리스트가 만들어진다. 
  4. 사실 엄밀하게 말하면 인덱스에 의한 원소 참조가 슬라이스의 특별한 한 경우라 할 수 있다. 

오일러 프로젝트 11

오일러 프로젝트 11번 – 격자내에서의 이웃한 네 수의 곱의 최대값 찾기

이번 문제는 격자를 탐색하는 문제이다.

아래와 같은 20×20 격자가 있습니다.

08 02 22 97 38 15 00 40 00 75 04 05 07 78 52 12 50 77 91 08
49 49 99 40 17 81 18 57 60 87 17 40 98 43 69 48 04 56 62 00
81 49 31 73 55 79 14 29 93 71 40 67 53 88 30 03 49 13 36 65
52 70 95 23 04 60 11 42 69 24 68 56 01 32 56 71 37 02 36 91
22 31 16 71 51 67 63 89 41 92 36 54 22 40 40 28 66 33 13 80
24 47 32 60 99 03 45 02 44 75 33 53 78 36 84 20 35 17 12 50
32 98 81 28 64 23 67 10 26 38 40 67 59 54 70 66 18 38 64 70
67 26 20 68 02 62 12 20 95 63 94 39 63 08 40 91 66 49 94 21
24 55 58 05 66 73 99 26 97 17 78 78 96 83 14 88 34 89 63 72
21 36 23 09 75 00 76 44 20 45 35 14 00 61 33 97 34 31 33 95
78 17 53 28 22 75 31 67 15 94 03 80 04 62 16 14 09 53 56 92
16 39 05 42 96 35 31 47 55 58 88 24 00 17 54 24 36 29 85 57
86 56 00 48 35 71 89 07 05 44 44 37 44 60 21 58 51 54 17 58
19 80 81 68 05 94 47 69 28 73 92 13 86 52 17 77 04 89 55 40
04 52 08 83 97 35 99 16 07 97 57 32 16 26 26 79 33 27 98 66
88 36 68 87 57 62 20 72 03 46 33 67 46 55 12 32 63 93 53 69
04 42 16 73 38 25 39 11 24 94 72 18 08 46 29 32 40 62 76 36
20 69 36 41 72 30 23 88 34 62 99 69 82 67 59 85 74 04 36 16
20 73 35 29 78 31 90 01 74 31 49 71 48 86 81 16 23 57 05 54
01 70 54 71 83 51 54 69 16 92 33 48 61 43 52 01 89 19 67 48 

위에서 대각선 방향으로 연속된 붉은 숫자 네 개의 곱은 26 × 63 × 78 × 14 = 1788696 입니다. 그러면 수평, 수직, 또는 대각선 방향으로 연속된 숫자 네 개의 곱 중 최대값은 얼마입니까? (http://euler.synap.co.kr/prob_detail.php?id=11)

오일러 프로젝트 11 더보기

Tail 과 꼬리재귀(Tail Recursion) – Swift

꼬리재귀

Natasha ElementTypehe Robot에 꼬리재귀에 대한 글이 올라오고 Digg에서 많은 digg을 얻었는데, 좀 이상해서 내용을 정리해본다. 링크한 글의 저자는 꼬리재귀와, 함수형 언어의 자료 구조인 리스트의 head, tail을 혼동하고 있는 듯 하다. 우선 꼬리재귀에 대해서 먼저 이야기하겠다. 꼬리 재귀는 재귀의 특별한 한 형태이다. 꼬리 재귀를 설명하기 전에 먼저 재귀(recursion)에 대해 알아보자.

재귀는 어떤 함수의 내부에서 스스로를 다시 호출하는 것을 말한다. 예를 들어서 1에서 10 까지의 자연수의 합을 구하는 과정을 재귀적인 처리를 통해서 구한다고 생각해보자.

  1. 계산은 1 + 2 + 3 + 4 + … + 10 으로 이루어지고, 편의상 이걸 +(1~10) 이라고 표현하기로 약속한다.
  2. 이 때 10까지의 합과 9까지의 합은 10만큼 차이난다, 즉 +(1~10)+(1~9) + 10 인 셈이다.
  3. +(1~n)+(1~(n-1)) + n 이 된다.

이 관계를 이용하면 1에서 n 까지의 자연수의 합을 구하는 함수를 다음과 같이 작성할 수 있다.

func sumUpto(n: Int) -> Int {
  guard n > 0 else { return 0 }
  return n + sumUpto(n: n - 1)
}

재귀함수의 기술적 한계

재귀함수는 1) 어디까지 계산할 것인가와 2) 한 단계의 계산과 다음 단계의 계산의 관계만을 생각하는 것으로 전체 계산 알고리듬을 매우 간결하게 정리할 수 있는 장점이 있다. 문제는 기술적인 한계때문에 재귀의 단계는 일정한 범위 이상으로 커질 수가 없다는 점이다. 그것은 재귀함수가 자신을 호출하는 것에 대해서 시스템은 부가적인 리소스를 더 많이 소모해야 한다는 것이다.

프로그램 흐름에서 함수의 호출은 메인 루틴에서 별도의 서브 루틴으로 흐름이 이행되는 것을 의미한다. 함수 내부로 실행 흐름이 들어가게 되면 함수 내부에는 전달된 인자와 함수 내부에서 선언된 지역 변수, 상수들이 존재하며 이것은 메인 루틴의 스코프와는 개별적인 값들이 된다. 또한 함수의 실행이 끝나면 실행 흐름은 메인 루틴에서 함수를 호출했던 위치로 돌아가야 하고, 이 때 실행 흐름이 액세스할 수 있는 변수들은 원래의 스코프의 값들이 되어야 한다. 이러한 리소스 제어를 위해서 함수가 호출되면 시스템은 메모리 영역의 끝단에 별도의 스택을 만들고 여기에 인자값과 함수의 지역 변수들을 복사한다. 그리고 함수의 실행이 끝나면 스택 영역을 파괴하여 리소스를 회수하는 식으로 동작한다.

함수의 재귀 호출은 함수는 하나지만, 함수가 매번 재귀 호출 될 때마다 별도의 독립적인 컨텍스트가 요구되기 때문에 재귀 호출을 반복하면 반복할 수록 스택영역을 계속해서 추가적으로 사용해 나가야 한다. 하지만 당연하게도 시스템의 메모리 자원은 한정되어 있기 때문에 스택 영역의 크기는 제한된다. 통상 몇 천~몇 만 단위의 횟수 내에서 스택이 중첩될 수 있고 (이것은 언어나 컴파일러마다 다르다.) 따라서 재귀의 깊이 역시 제한된다.

그런 이유에서 위의 sumUpto(n:) 함수는 몇 만 단위의 n 값에 대해서는 값을 계산하지 못하고 프로그램이 터지게 된다. 또한 시스템 입장에서 스택 영역을 할당하고 파괴하는 작업은 상당히 비싼 작업이다. 따라서 그만큼 성능 측면에서도 불리하다.

꼬리재귀 최적화

그런데 재귀 함수의 특정한 패턴 중에는 꼬리재귀(tail recursion)라는 것이 있다. 꼬리 재귀는 함수가 자신을 재귀호출한 결과를 바로 리턴하는 것을 의미한다. 꼬리 재귀가 특별한 이유는 다음과 같다.

  1. 꼬리 재귀에서 재귀의 결과는 그대로 리턴되므로 재귀의 결과에 대한 추가적인 연산이 불필요하다.
  2. 재귀 결과에 추가적인 연산이 불필요하다는 점은, 재귀 결과를 받은 시점에 해당 함수 내의 컨텍스트를 더 이상 참조하지 않는다는 의미이다.
  3. 그렇다면 재귀 호출에 진입하는 시점에서, 해당 레벨의 컨텍스트가 필요하지 않다는 것을 의미한다.
  4. 재귀 호출되었을 때 새로 생성해야 할 컨텍스트는 사실상 현재 컨텍스트와 동일하다.

이 말이 무엇을 의미하는가? 꼬리 재귀에서는 재귀 호출로 새로운 스택을 만들고 새 함수 컨텍스트로 실행 흐름을 옮기는 대신에, 현재 함수의 맨 처음으로 실행 흐름이 점프하면 된다는 의미이다. 즉 꼬리 재귀는 그 흐름이 루프로 치환된다는 것이다. 게다가 꼬리 재귀를 판단하는 것도 매우 간단해서 return 문에 재귀 호출구문이 있고 그외의 표현식이 없으면 된다. 따라서 컴파일러는 이러한 꼬리 재귀 패턴을 간단하게 루프로 치환할 수 있고, 그렇게 하여 스택 생성과 파괴에 따른 메모리 및 성능 낭비를 막을 수 있다. 이것을 꼬리 재귀 최적화라고 한다.

앞서 sumUpto(n:)은 재귀 호출 결과에 n을 더한 후에 리턴하기 때문에, 재귀 호출한 결과를 다시 가공하여 호출하고 있기에 꼬리 재귀가 아니라 하였다. 왜냐하면 재귀의 결과에 다른 값을 누적해서 더해야하기 때문이다. 따라서 누적값을 인자로 넘겨서 이러한 형태를 꼬리 재귀로 변경할 수 있다.

func sumUptoByTailRecursion(n: Int, _ acc: Int = 0) -> Int {
  guard n > 0 else { return acc }
  return sumUptoByTailRecursion(n: n - 1, acc + n) // 위로부터 누적하여 아래로 내려보낸다.
}

이렇게 변경된 형태는 꼬리 재귀이고, 이제 동일한 연산에 대해서 컴파일러가 최적화 할 수 있게 되었다.

Swift 컴파일러는 꼬리 재귀 최적화를 수행하는 것으로 보이지만, 실제로는 최적화가 적용되지 않는 경우가 있다. 그것은 ARC때문에, 컴파일러가 최적화를 수행하는 이전 단계에서 메모리 관리 코드를 여기 저기에 삽입할 수 있기 때문이다. 따라서 소스 코드 원안에서 꼬리 재귀 형태였던 것이 ARC에 의해서 모양이 바뀔 수 있다. 이러한 문제를 피하기 위해서 트램폴린이라는 기법을 사용할 수 있다. (트램폴린 기법은 명시적으로 재귀를 루프로 바꿀 수 있기 때문에 최적화가 훨씬 쉬우며, 심지어 꼬리 재귀 최적화를 지원하는 다른 언어에서도 문법적으로 구현이 가능하다면 실제 꼬리 재귀보다 좋은 성능을 보인다.)

리스트의 꼬리에 관하여

나타샤가 헷갈려한 tail은 무엇일까? 그것은 리스트라는 함수형 언어에서의 주력 데이터 타입에서 사용되는 용어이다. 그러려면 “리스트”라는 타입에 대해서 살펴보아야겠다. 리스트는 배열처럼 여러 개의 개별 원소값이 일렬로 나열된 순서가 정해진 집합을 나타낼 때 사용한다. 그럼 “연결리스트(linked list)”하고 비슷한 것인가라고 생각할 수 있는데, 연결리스트와는 다르다. 연결리스트는 원소값을 감싸는 노드가 자신의 다음 노드에 대한 참조를 가지고 있는 것인데, 리스트는 다음 원소와의 연결이 “연산자”에 의해 고정된다.

하스켈에서 리스트를 만드는 빌딩 블럭으로는 두 개의 표현이 사용되는데,

  1. [ ] 은 빈 리스트를 의미한다.
  2.  : 연산자는 원소:리스트 의 형태로 어떤 리스트의 앞에 하나의 원소를 결합하는 작용을 한다.

위 표현을 활용하면 얼마든지 긴 리스트를 만들 수 있다. 예를 들어 [1] 이라는 1개 원소를 가지는 리스트는 빈 리스트에 1이라는 원소를 붙인 것이므로 1:[ ] 로 표현할 수 있다. 그럼 [1, 2]는? 1:(2:[])가 된다. 이런 식으로 1:(2:(3:(4:(5:[]))))와 같이 표현되는 것을 (으아 괄호지옥이다!!!) 문법적으로 쓰기 편하게 [1,2,3,4,5]라고 표현하는 것이다.

리스트는 결국 맨 앞의 원소 하나와 그걸 제외한 나머지 리스트가 붙어있는 재귀적인 꼴로 정의된다. 여기서 맨 앞의 원소를 head, 나머지를 tail이라고 한다. 그렇다면 tail에 대한 재귀적인 처리를 tail recursion이라고 할까? 아니다. 리스트의 본질은 그 자체가 재귀적인 데이터 타입이기 때문에 리스트에 대한 거의 대부분의 연산이 재귀적으로 이루어진다. 리스트의 합을 구하는 sum 이라는 함수를 정의한다고 하면 하스켈에서는 다음과 같이 정의할 수 있다.

sum :: Integral a => [a] -> a
sum [] = 0
sum (x:xs) = x + (sum xs)

하스켈은 선언적인 함수이기 때문에 변수라는 개념이 없다. x = 1 과 같은 식으로 값에 이름을 붙일 수 있지만 이것은 엄밀하게 1 이라는 항등함수 x를 의미하는 것이다. 따라서 각 원소의 누적값을 더해나갈 임시 변수같은 것이 존재하지 않기 때문에 리스트에 대해서 루프를 통한 연속적인 연산은 불가능하며, 리스트의 재귀적인 성질에 의존하는 재귀적인 처리만이 가능하다. 그리고 (x + sum xs라는 표현 자체는 꼬리 재귀의 모양도 아니다.)

참고로 하스켈의 리스트는 Swift에서도 구현할 수 있다. 열거체는 indirect 변경자를 사용해서 재귀적으로 정의가능하다.

enum List<T> {
  case empty
  case list(T, List<T>)
}

// 빈 리스트
let anEmptyList: List<Int> = .empty
// [1]
let one: List<Int> = .list(1, .empty)
// [1,2,3,4,5]
let oneToFive: List<Int> = .list(1, .list(2, .list(3, .list(4, .list(5, .empty)))))

// 그리고 합계를 구하는 함수
func sum(list: List<Int>) -> Int {
  switch list {
  case .empty: return 0
  case .list(let x, let xs): x + sum(list:xs)
  }
}

접기

다시 원글에서 나타샤는 reduce에 대한 이야기를 하고 있다. 리스트를 하나의 값으로 축약하는 행위는 엄밀하게 말하면 재귀적인 특성이 아니라 항등원을 갖는 이원연산과, 그 연산의 항등원에 관한 성질 때문이다. 이러한 연산은 두 개의 값을 하나로 합칠 수 있고, 하나의 값이 있을 때에는 항등원을 이용해서 연산을 적용할 수 있다. 이러한 연산과 항등원을 모노이드라고 하는데, 모노이드로부터 Foldable이라는 특성을 규정한다. 따라서 재귀와 아무런 관련이 없는 배열이나, 옵셔널등도 모노이드의 성질을 가지며 reduceflatMap과 같은 연산을 적용받을 수 있다.

 

[Python101] Iterable (1) – 리스트

지난 시간 for 문을 설명할 때 다음과 같은 문법이 잠깐 등장했다.

for number in range(1,10):

이 구문은 range() 함수를 사용해 만들어지는 1~9 까지의 숫자의 ‘집합’의 개별 원소에 대해 반복적인 명령을 수행하는 구문이라고 했다. 이러한 집합은 사실 영어로 ‘iterable’이라고 하지만 우리말로는 딱히 정확히 대응시킬만한 말이 없다는 것도 이야기했다. 이번 시간에는 이 iterable에 대해서 알아보고자 한다.

* 이번 시간은 IDLE의 대화형 쉘을 통해 직접 확인해보면서 배우는 것이 좋다.

지난 시간에서 사용한 ‘집합’이라는 표현이 어찌보면 가장 근접한 표현일 수도 있다. iterable이라는 말은 ‘집합’ 그 자체보다는 “개별 원소를 반복적으로 셀 수 있는”이라는 문맥적인 의미가 있기 때문이다. 파이썬에서 사용하는 집합에는 1) 리스트, 2) 튜플, 3) 사전(dictionary) 가 있다. 각각은 약간의 차이점은 있으나, 대체로 for 문과 같은 반복 작업에서 원소들을 일일이 열거 한다는 점에서 공통점을 가진다.

리스트

프로그래밍 언어를 배울 때 빠지지 않고 등장하는 개념이 있으니, 바로 ‘배열(array)’이다. 배열은 수학에서의 ‘집합’과 매우 유사하기도 한데, 파이썬의 리스트는 바로 이 배열과 거의 같은 개념이라 볼 수 있다.

리스트는 여러 개의 원소를 포함하는 하나의 집합체이다. 리스트에 포함되는 개별 값들을 원소라 할 수 있는데, 이 원소들은 모두 일정한 순서를 가지고 있다. 특정한 원소의 순서를 ‘인덱스’라고 한다.

리스트는 대괄호로 여러 값들을 연속해서 둘러 싸 만들 수 있다. 이 때 각 값들은 컴마(,)로 구분된다.

a  = [2,3,5,7,11,13,17,19]

a 라는 변수에 20보다 작은 소수(prime number)로 구성된 리스트를 만들어 대입했다. 이 때 작은 수 부터 큰 수의 순서로 쓴 것은 단지 편의의 문제일 뿐이다.

b = [5,19,2,7,13,11,3,19]

와 같이 불규칙한 순서를 써서 만든 리스트 b는 구성하고 있는 원소는 a와 같지만 각각의 원소의 순서가 다르다. (따라서 둘은 완전히 다른 리스트이다.)

하나의 원소의 인덱스

리스트의 각 원소는 정해진 순서가 있다고 했다. 이 때의 순서값을 ‘인덱스’라고 하며, 인덱스는 0부터 시작한다. 즉 첫번째 원소의 인덱스는 항상 0 이다.

리스트로부터 특정한 인덱스에 위치한 원소를 지정하려면 리스트이름[인덱스]와 같은 식으로 접근하게 된다.

print a[2]
#==> 5

마찬가지로 c = a[5] 와 같은 식으로 특정 원소를 다른 변수에 대입하는 것도 가능하다.

파이썬의 리스트는 재미있게도, 독특한 인덱스를 취급한다. 바로 음수 인덱스이다.

print a[2]
print a[-2]

위 명령은 리스트 a의 뒤에서 두 번째 원소를 가리킨다. 즉 17을 출력하게 된다.

리스트의 부분집합

리스트는 수학적 개념의 ‘집합’과도 매우 유사하다고 했다. 그리고 인덱스를 사용해서 특정한 원소에 접근하는 것이 가능하다고도 했다. 이와 마찬가지로 인덱스를 사용하여 부분 집합을 정의할 수 있다.

c = a[2:5]
print c

위의 코드는 리스트 a로부터 부분집합인 c를 추출하는 과정을 보여준다. a[2:5]는 2번째 인덱스에서 5번 인덱스까지를 말하는데, 주의할 것은 뒤쪽 인덱스는 포함하지 않는다. 즉 2번, 3번, 4번의 인덱스에 해당하는 원소만이 추출되는 것을 확인할 수 있다.

만약 :을 쓰고 한쪽을 비운다면 끝까지에 해당한다.

d = a[3:] # -> [7, 11, 13,19]
#인덱스           3   4   5  6
e = a[:5] # -> [2, 3, 5, 7, 11]
#인덱스           0  1  2  3   4

이 때도 뒤쪽 인덱스는 포함하지 않는 다는 점에 주의하자. 이는 range() 함수에서도 동일하게 적용되었다. range(2,10) 은 2에서부터 숫자 범위를 리스트로 만들어서 반환해 준다. 이 때 뒤쪽에 들어가는 10은 포함되지 않아서 9까지만 들어가게 된다. 여기서 중요한 것! 바로 range()  함수가 결과값을 list로 반환해준다는 것이다.

(시작값, 끝값) ==> range() ==> [시작값 ~ 끝 값]으로 된 리스트

문자열과 리스트

list는 리스트를 지칭하는 파이썬의 예약된 단어이다. “리스트라는 데이터 타입”을 의미한다. 같은 이름의 함수인 list()는 특정한 객체를 리스트로 만들어준다. 문자열은 한글자, 한글자의 문자가 이어져서 단어나 문장이 된 텍스트 정보를 의미하는데, 이는 한글자씩으로 만들어진 리스트와 비슷하지 않은가?

str = 'elephant'
g = list(str)
print g
# ['e','l','e','p','h','a','n','t']

문자열과 리스트를 오가는 표현은 이후로도 자주 등장할 것인데, 아마도 별도의 챕터로 분리해서 설명하는 것이 좋겠다는 생각이 든다.

list 는 파이썬에서 “리스트라는 데이터 타입”을 의미한다고 했다. 따라서  dir(list)라고 해보면 리스트가 가지고 있는 기능들을 열람할 수 있을 것이다.

dir() 명령을 통해 확인할 수 있는 리스트의 동작은 다음과 같다. (각각의 명령에 대해서 도움말은 help(list.pop) 과 같은 식으로 찾아볼 수 있다.

  • append(x) : 리스트의 끝에 새로운 원소 x를 추가한다.
  • count(x) :  리스트에서 x 라는 원소가 몇 번 들어있는 지 세어본다.
  • extend(x) : 리스트에 새로운 리스트 x를 연결해준다.
  • index(x) : 리스트에서 x라는 원소의 인덱스를 구해준다. 이 때 x가 두 개 이상 들어있다면, 맨 처음 x만 찾는다.
  • insert(인덱스, x) : 현재 리스트의 주어진 인덱스 위치에 x라는 원소를 끼워넣어준다.
  • pop() : 리스트의 맨 마지막 원소를 반환하고, 해당 원소를 원래 리스트에서 제거한다. 만약 pop(x) 라고 하면 x를 반환하고, 리스트는 x를 제거한다.
  • remove(x) : 리스트에 포함된 원소 x를 제거한다. (pop과는 달리 뭔가 반환하지는 않는다.)
  • reverse() : 리스트를 역순으로 바꾼다.
  • sort() : 리스트의 원소들을 정렬한다.

리스트를 다루는 함수

리스트 자체가 제공하는 함수는 ‘메소드’라고 부른다. reverse, pop, sort 등은 리스트의 메소드이다. 앞서 정의한 a의 경우에는 다음과 같이 실행해 볼 수 있다.

a.reverse()
print a # --> [19, 17, 13, 11, 7, 5, 3, 2]
a.pop()
#--> 2
print a #--> [19, 17, 14, 11, 7, 5, 3]
a.sort()
print a #--> [3, 5, 7, 11, 13, 17, 19]

이러한 리스트 자체의 메소드 외에도 몇 가지 리스트와 관련된 기본 함수들이 있다. 이 중에서 가장 자주 쓰이는 것은 len()sorted() 함수이다. len 함수는 리스트의 원소의 총 개수를 (즉 리스트의 길이를) 반환하고, sorted() 함수는 인자로 받은 리스트를 정렬한 사본을 반환한다. sorted() 함수는 원본 리스트의 원소의 순서를 바꾸지 않는다. 반면, 리스트의 list.sort() 메소드는 원본 리스트의 순서를 바꾼다.

그리고 list()라는 함수는 위에서도 잠깐 살펴보았지만, 리스트로 변경이 가능한 데이터형을 쪼개거나 변환하여 리스트로 만들어 반환한다. 문자열을 리스트로 바꾸거나 다른 “집합” 형식인 튜플을 리스트로 바꿀 수 있다.

리스트의 효용

다른 프로그래밍 언어에서도 배열은 매우 중요한 데이터 형식으로 취급한다. 일련의 데이터를 한 덩어리로 다루거나, 목록으로 관리하거나 하는 등 실질적인 어플을 만들 때 상당히 많이 적용된다.

또한 “스택”이나 “큐”와 같은 개념 역시 배열을 이용해서 구현한다. (스택이나 큐는 다른 글을 통해서 알아보도록 하자. 혹은 구글에서 검색을 해봐도 좋다.)

또한 문자열과 리스트를 서로 변환해가면서 처리하는 것 역시 매우 유용하게 활용된다.

다음 시간에는 리스트를 문자열과 어떻게 함께 사용하여 활용하는지, 그리고 조금 더 멋진 “지능형 리스트”란 무엇인지를 잠깐 살펴보겠다.