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

지난 시간에 파이썬의 기본적인 값 타입에 대해서 살펴보았는데, 그 때 소개했던 리스트에 대해서 조금 더 자세히 알아보도록 하겠다. 리스트를 엄밀하게 정의하면 “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. 사실 엄밀하게 말하면 인덱스에 의한 원소 참조가 슬라이스의 특별한 한 경우라 할 수 있다.