내가 학교에 다닐 때는 반마다 학생들에게 번호가 붙어 있었다. 선생님은 숙제를 검사할 때나 소풍을 갈 때 학생들을 번호 순서로 줄 세우기도 했다. 줄 서는 건 시간도 걸리고 귀찮은 일이라, 선생님께 왜 줄을 서야 하냐고 물어보곤 했다. 그때마다 선생님은 빠진 학생이 없는지 알아보기 위해서라고 답했다. 나는 그 때의 선생님처럼 데이터를 가지런히 줄 세워 놓기를 좋아한다. 순서를 정해 차례대로 나열하는 것은 많은 양의 무언가를 관리할 때의 기본 아닐까?

그림 5-1 컵, 음료수 같은 걸 줄 세운 사진 (준비중)

5.2.1 순서를 이용하는 컬렉션

시퀀스(sequence)는 데이터를 나열한 것이다. 데이터를 나열한다는 것이 무슨 뜻인지 사람 이름 목록을 예로 들어 이야기해 보자.

  • 홍길동, 임꺽정, 장길산, 전우치, 일지매

이 시퀀스에는 사람 이름이 왼쪽에서 오른쪽으로 한 줄로 나열되어 있다. 첫 번째 원소는 홍길동, 두 번째는 임꺽정, 마지막은 일지매다. “네 번째 데이터가 무엇이냐”라는 질문에 “전우치”라고 답하는 것도 가능하다. 이 예처럼 시퀀스는 데이터를 한 줄로 나열하며, 특정 위치의 데이터를 가리키는 것도 가능한 컬렉션이다.

시퀀스의 특징을 요약해 보자.

  • 데이터를 한 줄로 나열한 것이다.
  • 저장된 데이터에 순서가 있다.
  • 순서를 이용해 특정 위치의 데이터를 가리킬 수 있다.

나열과 정렬은 다르다

시퀀스의 데이터에 순서가 있다는 말을 데이터가 정렬되었다는 뜻으로 오해하면 안 된다. 날짜를 나열한 다음 시퀀스를 생각해 보자.

  • 2018-02-15, 2018-02-16, 2018-02-17

이 시퀀스에서 마지막 원소인 ‘2018-02-17’ 다음에 어떤 데이터를 추가한다고 할 때, 그 데이터는 무엇일까? 시퀀스에서 날짜가 하루씩 증가하고 있으므로 그 다음날인 ‘2018-02-18’가 추가되어야 한다고 생각할 수 있다. 아니면 이 시퀀스를 2018년의 공휴일 목록이라고 생각하여 그 다음 공휴일인 ‘2018-03-01’이 추가되어야 할지도 모른다.

그러나 일반적으로 시퀀스는 그 속에 담는 데이터들 사이의 규칙이 없고 어떤 데이터를 담아도 문제가 되지 않는다. 위의 날짜 시퀀스에 ‘1999-12-31’ 같은 과거의 날짜를 추가할 수도 있고, 심지어 날짜가 아니라 ‘안녕?’ 같은 뜬금없는 데이터를 추가해도 상관없다. (단, 담을 수 있는 데이터의 규칙과 유형이 정해진 예외도 있다. 레인지가 대표적이다.)

시퀀스에 담은 데이터의 순서는 단지 데이터의 나열 순서일 뿐이다. 순서가 있다는 것과 정렬되었다는 것은 의미가 다르다. 시퀀스의 데이터는 어떤 기준에 따라 정렬되었을 수도 있고 무작위로 나열되었을 수도 있다. 하지만 데이터를 관리하기 위한 나열 순서는 반드시 존재한다.

시퀀스의 종류

시퀀스는 순서가 있는 여러 가지 컬렉션을 포괄하는 개념이다. 리스트(list), 튜플(tuple), 레인지(range), 문자열(string) 등이 시퀀스에 해당되는 컬렉션이다.

이 중에서 리스트가 가장 일반적이고 대표적인 시퀀스다. 시퀀스는 다소 추상적인 개념이어서 시퀀스 자체보다는 시퀀스의 구현물인 리스트를 학습하는 것이 좀더 시퀀스를 이해하기 쉬운 방법이다. 리스트를 알아 본 뒤에는 튜플, 문자열 같은 다른 시퀀스도 쉽게 이해할 수 있다.

5.2.2 리스트

리스트는 다양한 데이터를 담을 수 있고 내용을 변경할 수 있는 시퀀스다. ‘리스트’라는 이름은 목록을 뜻하는 영어 단어다. 한국 프로그래머들 사이에서는 ‘목록’보다는 ‘리스트’라는 용어가 더 많이 사용된다.

리스트 표현하기

리스트는 대괄호([, ])를 이용해 표현할 수 있다. 다음은 몇 가지 리스트의 예다.

코드 5-3 리스트 표현하기

# 빈 리스트
[]

# 숫자로 구성된 리스트
[1, 2, 3, 4]

# 여러 유형의 데이터로 구성된 리스트
[10, 'hi', True]

대괄호는 리스트를 의미하며, 대괄호 안에는 원하는 만큼 원소를 입력할 수 있다. 이 때 원소와 원소는 콤마(,)로 구분해주면 된다. 아무 원소도 갖지 못한 빈 리스트([])도 리스트다. 리스트에는 어떤 데이터 유형이든 자유롭게 담을 수 있고 여러 데이터 유형을 뒤섞을 수도 있다. 하지만 실제 프로그래밍에서는 동일한 형태의 데이터를 담는 경우가 많다.

리스트에 이름 붙이기

컬렉션도 원자 데이터처럼 변수에 대입하여 이름을 붙여둘 수 있다. 이어질 실습을 위해, 대화식 셸을 실행하고 다음 리스트를 변수로 대입해 두자.

코드 5-4 리스트를 변수에 대입하기

>>> number_list = [1, 2, 3, 4, 5]
>>> alphabet_list = ['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h']

컬렉션의 이름 짓기

컬렉션의 이름을 지을 때는 원자 데이터의 이름과 구별되도록 하는 것이 좋다. 복수형 이름이나 컬렉션의 종류를 접미사로 붙이는 방식이 많이 사용된다.

  • 복수형 이름: numbers, names
  • 컬렉션의 종류를 접미사로 붙이기: _list, _dict, _set

연습문제

연습문제 5-1 수 리스트 정의하기

0 이상이고 100 미만인 모든 8의 배수의 리스트 multiples_of_8_list를 정의하라.

5.2.3 시퀀스 연산

앞에서 정의한 두 개의 리스트 number_list, alphabet_list를 조작하며 시퀀스 연산을 알아볼 것이다.

정수, 실수, 복소수 데이터 유형은 수치 데이터를 위한 동일한 연산 방식을 공유한다. 이와 마찬가지로 시퀀스들도 시퀀스 연산을 공유한다. 여기서는 리스트를 예로 들어 설명하지만 다른 시퀀스에서도 사용할 수 있다는 점은 염두에 두자.

원소 검사

어떤 시퀀스에 어떤 원소가 들어 있는지 확인하고 싶을 때는 in 연산자를 사용한다. 반대로 원소가 없음을 검사하려면 not in을 사용한다.

코드 5-5 원소 검사

>>> 3 in number_list       # number_list에 3이 들어 있는지 검사
True

>>> 'z' in alphabet_list   # alphabet_list에 'z'가 들어 있는지 검사
False

>>> 0 not in number_list   # number_list에 0이 안 들어 있는지 검사
True

길이 세기

시퀀스의 길이를 조사할 때는 len() 함수를 사용한다. 이 때 길이란 시퀀스에 들어 있는 원소의 개수를 뜻한다.

코드 5-6 시퀀스 길이 세기

>>> len(number_list)
5

>>> len(alphabet_list)
8

연결과 반복

덧셈 연산자와 곱셈 연산자를 사용해 시퀀스 데이터를 연결하거나 반복할 수 있다.

코드 5-7 시퀀스 연결과 반복

>>> number_list + alphabet_list  # 리스트 연결하기
[1, 2, 3, 4, 5, 'a', 'b', 'c', 'd', 'e']

>>> number_list * 2              # 리스트 반복하기
[1, 2, 3, 4, 5, 1, 2, 3, 4, 5]

어디서 본 것 같은데…

길이 세기, 연결과 반복 연산은 4.3.3 문자열 연산에서 소개한 것과 동일하다. 문자열도 시퀀스 컬렉션의 한 종류이므로 시퀀스 연산을 사용한다.

인덱싱: 특정 위치의 원소 가리키기

컬렉션에 들어 있는 특정 원소를 가리키는 것을 인덱싱(indexing) 연산이라고 한다. 변수의 이름으로 변수의 값을 가리키는 것처럼, 컬렉션의 원소를 가리켜서 그 원소를 구하거나 다른 값으로 수정하는 등의 작업을 할 수 있다.

인덱싱 연산을 표기할 때는 컬렉션[위치]와 같이 컬렉션 변수의 오른쪽에 인덱싱 연산자([])를 써서 나타낸다. 대괄호는 리스트를 작성할 때도 쓰는데, 인덱싱 연산을 할 때는 표기하는 위치가 다르니 잘 구분하자.

시퀀스에서는 구하려는 원소의 인덱스(index, 항목의 위치 번호)를 이용해 인덱싱한다. 다음 예는 alphabet_list에서 1번 위치와 -1번 위치의 데이터를 구하는 예다.

코드 5-8 리스트의 원소 가리키기

>>> alphabet_list[1]   # 1번 위치(두 번째)의 원소 가리키기
'b'

>>> alphabet_list[-1]  # -1번 위치(뒤에서 첫 번째)의 원소 가리키기
'h'

파이썬에서는 첫 번째 원소를 0번으로 하여 순서대로 번호를 매기기 때문에 0번 위치가 첫 번째 원소의 위치이고, 1번 위치는 두번째 원소의 위치다. 따라서 alphabet_list[1]의 평가 결과는 ['a', 'b', 'c', ... 'h']의 두 번째 원소인 'b'가 되었다. 또한,alphabet_list[-1]과 같이 음수를 인덱스로 지정할 수도 있다. -1번 위치란 뒤에서부터 첫 번째 위치, 즉 마지막 위치를 뜻한다. -0과 0은 구별되지 않으므로 뒤에서부터 셀 때는 -1이 첫 번째 위치다.

그림 5-2 시퀀스에서의 데이터 순서

그림 5-2 시퀀스에서의 데이터 순서

리스트는 저장된 값을 수정할 수 있는 컬렉션이다. 다음과 같이 인덱싱 연산을 이용해 특정 위치의 값을 교체할 수 있다.

코드 5-9 리스트의 원소 교체하기

>>> number_list[2] = '-3'  # 2번 위치(세번째) 원소를 교체
>>> number_list
[1, 2, -3, 4, 5]

가변 데이터와 불변 데이터

데이터 유형은 내용의 수정이 허용되는 것과 금지되는 것으로 구분할 수 있다. 리스트는 값을 변경할 수 있는 가변(mutable) 데이터로, number_list[2] = '-3'처럼 내용을 수정하는 연산이 가능하다. 반면, 수, 튜플, 문자열처럼 값을 변경할 수 없는 불변(immutable) 데이터도 있다. 튜플과 문자열의 불변성에 관해서는 이 장의 다른 부분에서 설명한다.

슬라이싱: 범위를 정해 선택하기

인덱싱 연산은 단 하나의 원소만을 가리키지만, 슬라이싱(slicing) 연산을 이용하면 일정한 범위의 원소를 선택할 수 있다. 슬라이싱 연산으로 선택할 범위를 지정할 때는 대괄호 속에 속에 콜론(:) 연산자로 시작 위치와 종료 위치를 구분해 표기한다. 이 때, 시작 위치는 범위에 포함되지만 종료 위치는 포함되지 않는다. (시작 위치 <= 범위 < 종료 위치)

코드 5-10 슬라이싱 범위 지정

>>> alphabet_list[2:6]   # 2 이상 6 미만 위치의 범위 선택
['c', 'd', 'e', 'f']

>>> alphabet_list[:3]    # 3 미만 위치의 범위 선택 (시작 위치 생략)
['a', 'b', 'c']

>>> alphabet_list[5:]    # 5 이상 위치의 범위 선택 (종료 위치 생략)
['f', 'g', 'h']

>>> alphabet_list[:]     # 전체 범위 선택 (시작, 종료 위치 모두 생략)
['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h']

그림 5-3 슬라이싱 범위 지정

그림 5-3 슬라이싱 범위 지정

코드 5-10에서 보듯 시작 위치와 종료 위치를 생략할 수도 있다. 시작 위치를 생략하면 처음부터, 종료 위치를 생략하면 마지막까지를 의미한다. 둘다 생략하면 시퀀스의 전체 범위가 선택된다.

슬라이싱 연산을 표현할 때 대괄호 안에 세 번째 값으로 스텝(step)을 지정할 수 있다. 걸음을 뜻하는 스텝은 몇 원소마다 하나씩 선택할 것인지 지정하는 값이다. 예를 들어 스텝을 2로 지정하면 두 원소마다 하나씩 선택한다. 스텝이 음수이면 뒤에서부터 역방향으로 선택한다.

코드 5-11 스텝을 지정하여 슬라이싱하기

>>> alphabet_list[::2]   # 전체 범위에서 두 원소마다 하나씩 선택
['a', 'c', 'e', 'g']

>>> alphabet_list[1::2]  # 1 이상의 범위에서 두 원소마다 하나씩 선택
['b', 'd', 'f', 'h']

>>> alphabet_list[::-1]  # 전체 범위에서 뒤에서부터 한 원소마다 하나씩 선택
['h', 'g', 'f', 'e', 'd', 'c', 'b', 'a']

그림 5-4 스텝을 지정하여 슬라이싱하기

그림 5-4 스텝을 지정하여 슬라이싱하기

시퀀스 복제

슬라이싱 연산을 통해 반환되는 시퀀스는 원본 시퀀스의 일부가 아니라 독립적인 사본이다. 시퀀스가 복제된다는 것이 무슨 뜻인지 실험을 통해 확인해보자.

코드 5-12 복제된 시퀀스는 원본과 독립적이다

>>> original_list = ['a', 'b', 'c', 'd'] 
>>> copied_list = original_list[:]   # 리스트 복제하기
>>> copied_list == original_list     # 두 리스트의 내용은 동일하다
True

>>> copied_list[0] = 'A'             # 사본의 원소 하나를 변경하면...
>>> copied_list                      # 사본의 내용은 수정되었다
['A', 'b', 'c', 'd']

>>> original_list                    # 그러나 원본의 내용은 수정되지 않았다
['a', 'b', 'c', 'd']

>>> copied_list == original_list     # 이제 두 리스트의 내용이 다르다
False

슬라이싱 연산을 수행하여 시퀀스를 복제한 뒤, 사본을 수정하더라도 원본은 수정되지 않는다는 점을 알 수 있다. 반면, 시퀀스를 다른 변수에 단순히 대입할 경우에는 이름만 두 개일 뿐, 두 이름이 가리키는 시퀀스는 동일하다.

코드 5-13 이름만 다를 뿐 동일한 시퀀스

>>> assigned_list = original_list    # 리스트를 다른 변수에 대입
>>> assigned_list[1] = 'B'           # 대입한 리스트를 수정
>>> original_list                    # 원본 리스트의 내용이 변경되었다
['a', 'B', 'c', 'd']

그림 5-5 시퀀스 복제와 단순 대입의 비교

그림 5-5 시퀀스 복제와 단순 대입의 비교

범위를 지정하여 수정하기

시퀀스에서 슬라이싱 표현으로 선택한 범위에 새로운 값을 대입하면 그 범위의 원소들을 다른 내용으로 교체할 수 있다. 이 경우에는 복제본이 생성되는 것이 아니라 원본 시퀀스가 수정된다. 선택한 범위의 크기와 새로운 내용의 크기는 달라도 된다.

코드 5-14 범위를 지정하여 수정하기

>>> number_list[1:3] = [200, 300]  # 1 - 3 위치를 선택해 새로운 내용으로 수정
>>> number_list
[1, 200, 300, 4, 5]

통계 함수

sum() 함수를 사용하면 시퀀스의 모든 원소의 합을 구할 수 있다.

코드 5-15 sum으로 모든 원소의 합 구하기

>>> sum(number_list)
510

min() 함수와 max() 함수를 사용해 시퀀스에 포함된 최소 원소와 최대 원소를 구할 수 있다.

코드 5-16 min, max로 최소, 최대 원소 구하기

>>> min(number_list)          # 가장 작은 원소
1

>>> max(number_list)          # 가장 큰 원소
300

>>> min(['가', '나', '다')    # 가장 작은 원소 (가나다순 비교)
'가'

>>> max(['가', '나', '다'])   # 가장 큰 원소 (가나다순 비교)
'다'

물론, 시퀀스에 포함된 원소는 크기를 서로 비교할 수 있는 대상이어야 한다. 그렇지 않을 경우에는 오류가 발생한다.

코드 5-17 잘못된 크기 비교

>>> min([1, 2, 3, 'a'])   # 잘못된 크기 비교
TypeError: '<' not supported between instances of 'str' and 'int'

지금까지 소개한 시퀀스 연산은 다양한 시퀀스에서 공통적으로 사용할 수 있다. 길이를 세는 len(), 인덱싱 연산, 슬라이싱 연산은 매우 자주 사용되는 연산이므로 반드시 코드를 따라 입력해 보고 익혀 두는 것이 좋다.

연습문제

연습문제 5-2 가운데 원소 찾기

center() 함수를 정의하라. 이 함수는 시퀀스를 하나 입력받는다. 입력받은 시퀀스의 원소의 개수가 홀수이면 정가운데에 있는 원소를 반환한다. 그리고 원소의 개수가 짝수이면 가운데 원소 두 개를 리스트에 담아 반환한다. 함수를 호출한 예는 다음과 같다.

>>> center(['가', '나', '다', '라', '마'])
'다'

>>> center([2, 4, 8, 16, 32, 64])
[8, 16]

연습문제 5-3 시퀀스 거울

mirror() 함수를 정의하라. 이 함수는 시퀀스를 하나 입력받아 그 시퀀스를 뒤집은 시퀀스를 원본에 덧붙여 반환한다. 단, 원본 시퀀스의 마지막 원소는 덧붙이지 않는다. 함수를 호출한 예는 다음과 같다.

>>> mirror([1, 2, 3])
[1, 2, 3, 2, 1]

>>> mirror(['가', '져', '가', '라'])
['가', '져', '가', '라', '가', '져', '가']

힌트: 슬라이싱 연산으로 순서를 뒤집은 리스트를 먼저 만들자.

연습문제 5-4 최대최솟값

minmax() 함수를 정의하라. 이 함수는 전달받은 시퀀스의 최솟값과 최댓값을 리스트에 담아 반환한다. 함수를 호출한 예는 다음과 같다.

>>> minmax([92, -21, 0, 104, 51, 76, -92])
[-92, 104]

>>> minmax(['파', '이', '썬', '프', '로', '그', '래', '밍'])
['그', '프']

연습문제 5-5 산술평균 1

mean() 함수를 정의하라. 이 함수는 시퀀스를 하나 입력받아 시퀀스 내 모든 원소의 산술평균을 반환한다. 함수를 호출한 예는 다음과 같다.

>>> mean([92, -21, 0, 104, 51, 76, -92])
30.0

>>> mean([])  # 빈 시퀀스를 전달받은 경우에는 None을 반환
None

힌트: 산술평균을 구하는 방법은 1) 모든 항목의 값을 합하고 2) 항목의 개수만큼 나누는 것이다.

5.2.4 시퀀스 조작 메서드

시퀀스를 조작할 때 많이 사용되는 메서드 몇 가지를 알아보자. 지금 소개하는 메서드는 시퀀스의 내용을 수정하는 메서드이기 때문에 불변 시퀀스에는 적용할 수 없으며, 가변 시퀀스인 리스트에 주로 사용된다.

메서드 용도
append(x) 원소 x를 시퀀스의 끝(오른쪽)에 추가한다
insert(i, x) 원소 x를 시퀀스의 i 위치에 삽입한다
extend(seq) 대상 시퀀스를 시퀀스의 끝에 연결한다.
pop() 시퀀스의 마지막 원소를 꺼낸다.
remove(x) 시퀀스에서 원소 x를 찾아 처음 발견된 것을 제거한다.
clear() 시퀀스의 모든 원소를 제거한다

표 5-1 시퀀스 조작 메서드

원소 추가, 삽입 메서드

append() 메서드는 매개변수에 전달된 데이터를 시퀀스의 끝에 추가한다.

코드 5-18 append() 메서드 사용하기

>>> numbers = [10, 20, 30, 40]
>>> numbers.append(100)    # 시퀀스의 끝에 100을 추가
>>> numbers
[10, 20, 30, 40, 100]

데이터를 시퀀스의 임의의 위치에 삽입할 때는 insert() 메서드를 사용한다.

코드 5-19 insert() 메서드 사용하기

>>> numbers.insert(4, 50)  # 시퀀스의 4번 위치에 50을 삽입
>>> numbers
[10, 20, 30, 40, 50, 100]

extend() 메서드는 매개변수로 전달한 시퀀스를 시퀀스 끝에 연결한다. 다른 시퀀스의 모든 원소를 통째로 추가하거나 시퀀스를 합칠 때 사용한다.

코드 5-20 extend() 메서드 사용하기

>>> numbers.extend([101, 102, 103])  # [101, 102, 103] 을 연결한다.
>>> numbers
[10, 20, 30, 40, 50, 100, 101, 102, 103]

extend() 메서드는 += 연산으로 대신해도 된다.

코드 5-21 += 연산은 extend() 메서드와 동일한 결과를 낸다

>>> numbers += [104, 105]  # numbers.extend([104, 105]) 와 동일
>>> numbers
[10, 20, 30, 40, 50, 100, 101, 102, 103, 104, 105]

원소 삭제 메서드

pop() 메서드는 시퀀스의 마지막 원소를 삭제하면서 반환도 한다. 마지막 원소를 꺼내 내용을 확인한 후 버리는 동작으로 생각하면 이해하기 쉽다.

코드 5-22 pop() 메서드 사용하기

>>> numbers.pop()  # 마지막 원소를 꺼내 확인하고 버린다
105

>>> numbers.pop()  # 마지막 원소를 꺼내 확인하고 버린다
104

>>> numbers.pop()  # 마지막 원소를 꺼내 확인하고 버린다
103

>>> numbers
[10, 20, 30, 40, 50, 100, 101, 102]

remove(x) 메서드는 특정 원소를 앞에서부터 찾아 처음 발견된 원소를 삭제한다.

코드 5-23 remove() 메서드 사용하기

>>> numbers.remove(40)  # 40을 찾아 삭제
>>> numbers
[10, 20, 30, 50, 100, 101, 102]

>>> numbers.remove(99)  # 데이터를 찾을 수 없는 경우에는 오류 발생
ValueError: list.remove(x): x not in list

clear() 메서드는 시퀀스의 모든 원소를 삭제한다.

코드 5-24 clear() 메서드 사용하기

>>> numbers.clear()
>>> numbers
[]

연습문제

연습문제 5-6 시퀀스 조작 연습

다음 프로그램을 실행했을 때 화면에 출력되는 결과를 예상해 보아라.

stations = []
stations.append('서울')
stations += (['수원', '대전'])
stations.extend(['밀양', '부산'])
stations.insert(3, '동대구')

print(stations)                 # 출력 1
print(stations.pop())           # 출력 2
print(stations.remove('수원'))  # 출력 3
print(stations)                 # 출력 4

5.2.5 리스트로 연락처 관리하기

리스트와 시퀀스 연산을 활용해 5.1 변수만으로 데이터를 관리할 수 있을까에서 발견한 연락처 관리 문제를 해결해 보자.

컬렉션을 사용하지 않았던 코드 5-2에서는 name_1, phone_1, name_2, phone_2, … 하는 식으로 프로그래머가 필요한 연락처 수만큼 변수를 하나하나 정의해야 했다. 저장해야 하는 연락처가 많을 수록 작성해야 하는 코드가 많아지는 데다, 연락처를 몇 개까지 저장해야 하는지가 정해져 있지 않을 때는 더욱 곤란하다.

리스트를 사용하면 이 수많은 변수를 ‘이름 목록’과 ‘전화번호 목록’이라는 두 리스트로 줄일 수 있다. 먼저 이름 목록과 전화번호 목록을 각각 빈 리스트로 정의한다.

코드 5-25 이름 목록과 전화번호 목록 정의

>>> name_list = []
>>> phone_list = []

새 연락처를 추가해야 할 때마다 리스트에 append() 메서드를 적용한다. 이 방법을 사용하면 직접 변수명을 붙일 필요가 없고, 지금까지 저장한 연락처가 몇 개인지 직접 세지 않아도 된다.

코드 5-26 연락처 추가, 연락처 개수 확인

>>> name_list.append('홍길동')        # 이름 목록에 추가
>>> phone_list.append('01234567890')  # 전화번호 목록에 추가
>>> name_list.append('임꺽정')
>>> phone_list.append('01234567891')
>>> len(name_list)                    # 저장된 연락처 개수 확인
2

리스트에 저장된 특정 위치의 연락처를 확인할 때는 인덱싱 연산을 사용한다.

코드 5-27 저장된 연락처 확인

>>> name_list[0] + phone_list[0]    # 0번 연락처 확인
'홍길동01234567890'

컬렉션을 사용함으로써 직접 변수를 정의할 필요가 없어졌고, 데이터를 더 유연하게 관리할 수 있게 되었다. 아직 이름 목록과 전화번호 목록이 하나의 연락처 목록으로 합쳐지지 못하고 구분되어 있다는 점이 신경쓰인다. 이 문제점은 5.4 매핑: 의미 있는 인덱스로 데이터 관리하기에서 해결해보기로 하자.

5.2.6 튜플

튜플은 다양한 데이터를 담을 수 있고 내용을 변경할 수 있는 시퀀스다. 리스트와 매우 흡사하지만, 튜플에는 한 번 담은 데이터를 바꿀 수 없다는 점이 리스트와 결정적으로 다르다. 튜플은 정의될 때부터 담을 데이터가 결정되어야 하고 그 뒤에는 데이터를 추가로 담거나 교체할 수 없다.

튜플 표현하기

튜플을 표현하는 방법은 튜플에 담을 원소가 몇 개인가에 따라 다르다.

  • 빈 튜플: ()
  • 원소가 하나인 튜플: (1,) 또는 1,
  • 원소가 둘 이상인 튜플: (1, 2) 또는 1, 2

리스트를 만들 때 [1, 2, 3]처럼 대괄호로 감싸는 것처럼, 튜플을 만들 때는 (1, 2, 3)처럼 괄호로 감싼다. 단, 리스트와는 달리 튜플에서 괄호는 필수가 아니다. 1, 2, 3과 같이 원소를 콤마(,)로 구분해 나열하기만 해도 올바른 튜플 표현이다. 그러나 콤마만으로는 빈 튜플을 표현할 수가 없기 때문에, 빈 튜플은 빈 괄호로 표현한다. 괄호가 필수인 경우는 빈 튜플밖에 없다. 그렇지만 빈 튜플과의 통일성을 지키고 튜플을 식별하기 좋도록 괄호를 붙이는 편이 좋다. 한편, 원소가 하나인 튜플을 작성할 때는 1이나 (1)과 같은 표현은 1이라는 원자 데이터로 인식되므로 (1,)과 같이 끝에 콤마를 붙여주어야 올바른 튜플 표현이다.

튜플은 구성요소를 변경할 수 없는 불변 데이터다. 연락처 목록처럼 수시로 데이터를 변경해야 하는 데이터는 튜플이 아니라 리스트로 작성해야 한다. 튜플은 데이터를 나열하되 그 순서나 내용이 변하지 않을 때 잘 어울린다. 예를 들어, 한 주의 요일 목록(일월화수목금토)은 변하지 않는 개념이므로 튜플로 표현하기에 적합하다.

코드 5-28 한 주를 구성하는 요일을 튜플로 정의

>>> days = ('일', '월', '화', '수', '목', '금', '토')  # 튜플 정의하기
>>> days  # 튜플 내용 확인
('일', '월', '화', '수', '목', '금', '토')

튜플에 시퀀스 연산 수행하기

튜플도 시퀀스의 일종이다. 리스트와 마찬가지로 시퀀스 연산을 수행할 수 있다.

코드 5-29 튜플의 시퀀스 연산

>>> len(days)    # 길이 세기
7

>>> days[0]      # 인덱싱 연산
'월'

>>> days[::-1]   # 슬라이싱 연산 (새 튜플 생성)
('토', '금', '목', '수', '화', '월', '일')

>>> days + ('천', '해', '명')  # 시퀀스 연결 (새 튜플 생성)
('일', '월', '화', '수', '목', '금', '토', '천', '해', '명')

그러나 튜플의 내용을 수정하는 연산은 지원하지 않는다.

코드 5-30 튜플은 내용을 수정하는 연산을 지원하지 않는다

>>> days[1] = '月'     # 원소 대입: 지원하지 않음!
TypeError: 'tuple' object does not support item assignment

>>> days.append('천')  # append 메서드: 지원하지 않음!
AttributeError: 'tuple' object has no attribute 'append'

5.2.7 레인지

(1, 2, 3, 4, 5, 6, 7, 8)과 같이 등차수열을 시퀀스로 표현해야 하는 경우가 많다. 그런데 이런 수열의 원소를 직접 입력하는 것은 수열의 범위가 넓을수록 불편하고 실수할 위험이 있다. 레인지(range)는 시작, 종류, 스텝의 간단한 규칙만으로 이런 수열을 표현하는 컬렉션이다. 레인지라는 용어는 어떤 범위나 구간을 뜻하는 영어 단어다.

레인지 표현하기

레인지를 표현할 때는 range() 함수를 사용한다. 이 함수에는 매개변수를 1개 또는 2개 또는 3개 지정할 수 있다. 지정하는 매개변수의 수에 따라서 생성되는 레인지가 다음과 같이 차이가 있다.

  • range(종료): 0 부터 종료값에 이르기 전의 1씩 증가하는 등차수열 시퀀스를 생성한다.
  • range(시작, 종료): 시작값부터 종료값에 이르기 전의 1씩 증가하는 등차수열 시퀀스를 생성한다.
  • range(시작, 종료, 스텝)) 시작값부터, 종료값에 이르기 전의 스텝 만큼씩 증가하는 등차수열 시퀀스를 생성한다.

range() 함수를 이용해 레인지를 생성해 보자. 레인지는 list() 함수를 이용해 리스트로 변환할 수 있다.

코드 5-31 레인지 표현하기

>>> list(range(9))         # 0 이상, 9 미만의 1씩 증가하는 등차수열
[0, 1, 2, 3, 4, 5, 6, 7, 8]

>>> list(range(5, 12))     # 5 이상, 12 미만의 1씩 증가하는 등차수열
[5, 6, 7, 8, 9, 10, 11]

>>> list(range(0, 20, 2))  # 0 이상, 20 미만의 2씩 증가하는 등차수열
[0, 2, 4, 6, 8, 10, 12, 14, 16, 18]

레인지는 원소를 필요한 시점에만 만들어낸다

앞의 예에서 레인지를 모두 리스트로 변환해서 내용을 확인했다. range() 함수를 IDLE의 대화식 셸에서 실행해보면, 레인지의 내용이 출력되지 않고 range(0, 9)와 같은 형태의 표현만 반환된다. 레인지의 내용을 확인하려면 list() 함수나 tuple() 함수로 감싸 레인지를 리스트나 튜플로 변환해주어야 한다.

코드 5-32 레인지의 내용은 리스트나 튜플로 변환해야 볼 수 있다

>>> range(9)  # 내용 대신 range(0, 9)라는 표현만 출력된다
range(0, 9)

>>> list(range(9))   # 리스트로 변환
[0, 1, 2, 3, 4, 5, 6, 7, 8]

>>> tuple(range(9))  # 튜플로 변환
(0, 1, 2, 3, 4, 5, 6, 7, 8)

레인지의 내용은 왜 바로 출력되지 않을까? 레인지는 원소를 계산하기 위한 규칙만 갖고 있고 원소 자체는 갖고 있지 못하기 때문이다. 레인지는 필요한 원소를 처음부터 미리 다 만들어두는 것이 아니라, 그 원소를 사용해야 하는 시점에 비로소 만들어낸다. (7.4 절에서 이 원리를 자세히 설명한다.) 그 덕분에 레인지를 이용하면 ‘0 이상 1 경 미만의 모든 정수’와 같이 매우 넓은 범위의 구간을 정의하는 것도 가능하다. 하지만 이것을 리스트나 튜플로 변환할 때는 오류가 발생할 수도 있으니 주의해야 한다. 다음 실험을 통해 확인해 보자.

코드 5-33 레인지 실험

>>> 경 = 10 ** 16    # 매우 큰 수
>>> range(경)        # 규칙만을 정의하므로, 레인지가 즉시 생성된다
range(0, 10000000000000000)

>>> list(range(경))  # 시퀀스 원소를 모두 생성하므로, 메모리가 부족해 오류가 발생한다
MemoryError

>>> range(경)[-1]    # 원소 하나를 읽는 것은 문제 없다
9999999999999999

레인지로 큰 범위를 정의하는 것은 문제가 없지만, 그 범위 전체를 리스트나 튜플로 실현하려 하면 메모리가 부족해 오류가 발생할 수 있다.

레인지에 시퀀스 연산 수행하기

레인지는 원소를 직접 갖지 않으므로 원소를 수정하는 것도 당연히 불가능하다. 따라서 레인지는 불변 데이터다. 튜플과 마찬가지로 내용을 수정하는 것을 제외한 시퀀스 연산을 레인지에 적용할 수 있다.

코드 5-34 레인지의 시퀀스 연산

>>> range(0, 100, 2)[10]       # 인덱싱 연산
20

>>> range(0, 100, 2)[10:20:2]  # 슬라이싱 연산 (새로운 규칙 생성)
range(20, 40, 4)

>>> range(0, 100, 2)[10] = 50  # 원소 대입은 지원하지 않는다.
TypeError: 'range' object does not support item assignment

레인지를 생성한 후 내용을 수정하고 싶다면, 레인지를 리스트로 변환한 후 수정하면 된다.

코드 5-35 레인지를 리스트로 변환한 후에는 수정이 가능하다

>>> numbers = list(range(10))
>>> numbers[5] = 100
>>> numbers
[0, 1, 2, 3, 4, 100, 6, 7, 8, 9]

연습문제

연습문제 5-7 레인지로 계산하기

레인지를 사용해, 0 이상 10000 미만인 모든 짝수의 합계를 구하라.

연습문제 5-8 레인지로 리스트 생성하기

레인지를 사용해, 9부터 0(포함)까지 거꾸로 나열한 리스트를 생성하라.

힌트: 레인지를 리스트로 변환하는 것을 잊지 말자

5.2.8 문자열은 시퀀스다

4.3 텍스트에서 문자열 데이터를 소개했다. 문자열은 시퀀스의 일종이다. 리스트와 튜플이 아무 데이터나 원소로 가질 수 있는 것는 반면, 문자열은 개별 문자만을 원소로 가진다. 문자열은 앞 장에서 자세히 설명했다. 여기서는 문자열의 시퀀스로서의 특징만 확인하자.

문자열은 시퀀스이므로 시퀀스 연산이 가능하다. 그러나 불변 데이터이기 때문에 내용을 수정하는 것은 허용되지 않는다.

코드 5-36 문자열의 시퀀스 연산

>>> message = '사막이 아름다운 것은 어딘가에 물을 감추고 있기 때문이야'
>>> '물' in message     # 원소가 들어있는지 확인
True

>>> message[17]         # 인덱싱 연산
'물'

>>> message[:2]         # 슬라이싱 연산
'사막'

>>> message[17] = '샘'  #  원소 수정은 불가하다
TypeError: 'str' object does not support item assignment

필요하다면 문자열을 리스트나 튜플로 변환할 수 있다.

코드 5-37 문자열을 리스트, 튜플로 변환하기

>>> list('파이썬')
['파', '이', '썬']

>>> tuple('일월화수목금토')
('일', '월', '화', '수', '목', '금', '토')

연습문제

연습문제 5-9 시퀀스 뒤집기

시퀀스를 입력받아 반대 순서로 뒤집어 반환하는 함수 reverse()를 정의하라. 그 후, 이 함수에 리스트, 튜플, 레인지, 문자열을 각각 입력해 결과를 확인해 보아라. 예를 들면 다음과 같다.

>>> reverse([10, 20, 30, 40])
[40, 30, 20, 10]

>>> reverse(tuple('일월화수목금토'))
('토', '금', '목', '수', '화', '월', '일')

>>> reverse(range(10))
range(9, -1, -1)

>>> reverse('파이썬 프로그래밍')
'밍래그로프 썬이파'

힌트: 시퀀스에서 슬라이싱 연산을 수행할 수 있다.