파이썬은 처음이라 – 또 이런 튜플은 처음이라

튜플은 임의의 값들을 괄호로 묶은 불변의 연속열이다. 튜플의 값은 어떤 것이든 될 수 있으며, 각각의 값은 정수 인덱스로 참조가 가능하다. 다만 튜플 그 자체가 불변이므로 리스트와 같이 원소를 추가/삭제하거나 변경하는 것은 불가능하다. 튜플은 괄호((   ))를 사용하여 콤마로 구분되는 값들을 써 넣어서 만들 수 있다.

튜플 생성하는 법

튜플은 괄호를 사용한 튜플 리터럴을 써서 만들 수 있으며, 기본 내장함수인 tuple()을 사용해서 리스트나 다른 연속열들을 튜플로 변형할 수 있다.

## 튜플 리터럴을 통해서 생성하는 법
(1, "A")
## 튜플의 값은 서로 다른 타입일 수 있으며, 어떤 값이 와도 무방하다.
(0, True, None, [1,2,3])
## 빈 튜플은 생성할 수 없다.
(, ) ## Syntax Error
## 1개짜리 원소의 튜플을 만들 수 있다. 이 때 끝에 콤마를 써야 한다.
(1, )
(1) == 1 ## 콤마를 쓰지 않으면 표현식의 일부가 되므로 단원자 튜플을 만들 수 없다.

## 바인딩 구문의 우변인 경우에 괄호를 생략할 수 있다. 
a = 1, 2
## a -> (1, 2)

튜플의 특징

튜플은 리스트와 많은 특성을 공유한다. 실제로 튜플은 값들이 그 순서를 가지고 있는 순서쌍이기 때문에 연속열이나 반복가능 객체로서의 특성을 가지고 있다. 다만 고유의 특성 때문에 다음과 같은 차이를 보인다.

  1. 빈 튜플은 만들 수 없다.
  2. 원소를 추가/삭제하거나 교체할 수 없다.
  3. 불변이기 때문에 변하지 않는 해시값을 가지며, 이는 사전의 키로 사용될 수 있다는 의미이다.

언패킹 문법

어찌보면 튜플은 이런 저런 조작이 불가능하기 때문에 그다지 활용할 곳이 많지 않아보인다. 하지만 튜플을 사용하면 엄청나게 복잡하거나 귀찮은 작업들을 공짜로 처리할 수 있다. 그 중 대표적인 문법이 바로 언패킹 문법이다. 언패킹 문법은 바인딩 구문의 좌변이 튜플 리터럴이고, 우변이 튜플 및 다른 연속열일 때, 각 매칭되는 자리끼리 바인딩이 일어나게 하는 것이다.

예를 들어 어떤 변수 a, b 가 가리키는 값을 서로 바꾸는 작업을 수행한다고 해보자. 보통 변수 스왑은 임시로 값을 담아둘 추가 변수를 필요로 한다.

a = 3
b = 5
## ---                          a   b   c
c = b  ## b를 임시변수로 옮기고     3   5   5
b = a  ## b는 a의 값을 가리킨다.   3   3   5
a = c  ## 다시 a는 c의 값으로      5   3   5

튜플 언패킹은 이럴 때 유용하다.

a, b = b, a

좌변의 a, b 는 각강 우변의 b, a에 대해서 매칭되어 바인딩되므로 스와핑을 한 번에 할 수 있다. 그런데 튜플은 꼭 2개의 원소 되어 있으라는 법이 없다는 말 기억하는지?

a, b, c, d, e = e, c, d, b, a

원하는 만큼 얼마든지 한 번에 스와핑할 수 있다. 만약 다섯개 변수를 각각 스와핑하는 코드를 작성한다고 해보라. 정말 간단한 일일까? 언패킹은 이러한 복잡한 과정을 단칼에 해결해주는 멋진 문법이다.

언팩연산자

여기서 잠깐, 언팩연산자(unpack operator)에 대해서 살펴보자. 파이썬 코드들 중에는 함수 정의를 이런 식으로 해놓은 것들이 있다.

def some_func(a, b, *args, **kwds):
  pass

여기서 *args의 앞머리에 있는 *는 언팩 연산자로 사용된다. 언팩 연산자는 바인딩 시에 여러 개의 값이 이 이름에 튜플로 바인딩된다는 의미이다. **kwds에서 **는 이름이 붙은 인자들을 사전으로 언패킹한다는 의미이다. 튜플 언팩 연산자는 바인딩 구문에서도 활용될 수 있다. 우변의 연속된 값들이 해당 이름의 튜플이 된다는 의미이다.

aList = list(range(10))
zero, one, *nums, nine = aList
## zero -> 0
## one -> 1
## nums -> (2, 3, 4, 5, 6, 7, 8)
## nine -> 9

튜플이 자주 쓰이는 곳

튜플이 자주 쓰이는 기본 내장함수 세 가지를 소개하겠다.

  • divmod(x, y) : divmod() 함수는 주어진 두 수의 몫과 나머지를 계산하여 튜플로 리턴한다.
  • enumerate(seq) : enumerate() 함수는 주어진 반복가능 객체에 대해서 (인덱스, 원소) 쌍으로 이루어진 튜플로 반복문을 돌릴 수 있게 해준다.
  • zip(A, B) : zip() 함수는 두 개의 서로 다른 연속열 A, B에 대해서 같은 인덱스의 원소끼리 묶은 튜플의 반복자를 만든다. 두 개의 연속열이 지퍼를 닫는 것처럼 하나로 붙어버린다는 뜻이다.

이중 divmod()zip()은 특정한 상황에서만 쓰인다고 볼 수 있지만, enumerate()는 정말 잘 알아둘 필요가 있다. 인덱스값과 원소가 모두 필요한 경우에 이런식으로 반복문을 작성하는 경우가 있다. 물론, 이 코드는 굴러가는 코드는 맞지만 파이썬 코드가 아닌 파이썬으로 쓰여진 C 코드나 다름없다.

aList = [2, 4, 5, ... , 101]
for i in range(len(aList)):
  item = aList[i]
  ...

이런 경우에 enumerate() 함수를 쓰면 된다. for ... in 사이에는 언패킹 문법으로 튜플의 각 원소에 매칭할 이름을 두는 것이 좋다.

for i, item in enumerate(aList):
  ...

언패킹 문법은 함수 호출시에도 쓰일 수 있다. 함수의 각 인자에 대응하는 튜플이 있다면, *를 붙여서 함수뒤의 괄호에 넣어준다. 그러면 튜플의 각 요소가 함수의 각 인자가 되어 들어간다.

pair = (2, 3)
add(2, 3) #-> 5
add(*pair) #-> 5

## 조금 고급. 두 리스트의 각 원소를 순서대로 짝지어 곱한 리스트를 만들기
evens = [i*2 for i in range(1, 10)]
odds = [i*2-1 for i in range(1, 10)]
## evens, odds의 각 자리 원소를 곱하려면?
result = [ operator.mul(*p) for p in zip(evens, odds) ]

위 예제의 마지막 코드를 잠깐 설명하자면 다음과 같다.

  1. zip()을 사용해서 evens, odds를 하나로 묶는다.
  2. 각각의 pair는 (2, 1), (4, 3), (6, 5) … 이 될 것이다.
  3. mul() 함수는 두 수를 받아서 곱하는 함수인데 (우리가 곱하기 연산자를 쓰면 실질적으로 이 함수를 쓰는 셈이다.), 여기에 튜플을 mul(*p)와 같이 풀어 넣으면 mul(2, 1)이 된다.

튜플과 함수 디자인

튜플은 2개 이상의 값을 하나로 묶어서 전달하거나 보관할 때 유용하게 사용할 수 있다. 2개 이상의 값이 하나의 값으로 묶이는 특성 덕분에 함수에서 매우 유용하게 사용된다. 즉 함수는 2개 이상의 값을 동시에 리턴할 수 있다.

def add_mul(x, y):
  return x+y, x*y

함수가 2개 이상의 값을 리턴할 수 있다는 점은 사실 프로그램 디자인에서 매우 큰 의의를 가지는데, 그것은 함수 내에서 전역 변수를 변경할 필요가 없다는 것이다. 예를 들어서 어떤 변수를 증가시키면서 그 누적값을 쌓는 함수가 있다고 가정하자.

acc = 0

def advance(step):
  global acc
  acc += step
  return step+1

위 함수는 step 값을 주고 실행할 때마다 해당 step 값을 acc라는 변수에 더하고 다시 step+1 값을 리턴한다. 이러한 함수 디자인은 사실 좋지 못하다. 왜냐하면 advance() 함수를 호출했을 때 그 부작용으로 전역변수 acc의 값이 변하기 때문이다. 이 함수의 정확한 구현을 모르는 상태에서 사용하면 알지 못하는 사이에 acc 값이 변하게 된다. (그리고 이것은 어딘가에서 문제가 될 확률이 매우 높다.)  C에서는 함수 내에서 상위 스코프의 변수를 변경하는데 아무런 제약이 없다. 따라서 아래 코드는 편리해보일지는 몰라도 문제를 일으킬 소지가 높은 예이다.

int acc = 0, i = 0;

int advance(int step) {
  acc += step;
  return step + 1;
}

while(i<10) {
  i = advance(i);
}
// acc = 45

이러한 부작용을 줄이기 위해서 C에서도 함께 변경되는 외부 변수값이 있다면, 그것을 포인터로 받아서 명시하는 식으로 디자인한다.

int advance(int step, int* acc){
  *acc += step;
  return step + 1;
}

파이썬에서는 또 다시 다른 디자인을 사용해야 한다. 함수는 2개 이상의 값도 한 번에 리턴할 수 있기 때문에 명시적으로 전역변수와 지역변수를 구분하는게 좋다.

acc, step = 0, 1
def advacne(step, acc):
  return step + 1, acc + step

while step < 1:
  step, acc = advance(step, acc) ## 전역 범위에서 acc, step의 값이 변경된다.
  print(step, acc)

참고로 위 예제에서도 한 가지 더 개선해야 할 부분이 있다. 그것은 함수 advance()의 인자 이름이 공교롭게도 전역 변수 이름과 같다는 것이다. 물론 함수 인자는 함수의 지역 변수로 취급되기 때문에 매번 호출될 때마다 안전하게 인자를 참조한다는 점은 변함이 없으나, 가능하면 인자의 이름이 전역 변수와 구분되도록 하는 것이 좋다.

 

이상으로 별로 중요하지 않게 보이지만 엄청 중요한 튜플에 대해서 알아보았다. 초급자의 경우에 튜플을 등한시하는 경향이 있는데, 중급에서 고급으로 올라가려는 시점에서 튜플에 익숙해져 있고, 또 얼마나 적절하게 활용하느냐 하는 것은 단순히 간결한 코드 뿐만 아니라 코드에 사용되는 로직이 얼마나 분명하고 투명해지느냐에 까지 영향을 주는 중대한 부분이다.