파이썬은 처음이라 – 느긋하긴 처음이라

흔하지 않은 컨셉이기는 하나 느긋함(lazyness)이라는 컨셉은 코드를 바라보는 시각을 크게 바꿀 수 있는 중요한 지점이 될 수 있다. 이 글에서는 파이썬에서 느긋함이란 무엇이며, 파이썬에서는 어떻게 적용되는지, 그리고 이 컨셉을 통해서 기존 코드를 어떤식으로 개선할 수 있는지에 대해 살펴보도록 하겠다.  다음은 이 글의 내용과 예제 코드를 이해하는데 필요한 몇 가지 사전 정보이다. 

파이썬은 처음이라 – 느긋하긴 처음이라 더보기

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

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

튜플은 처음이라

튜플은 여러 개의 값을 묶어서 마치 하나의 값처럼 다룰 수 있는 데이터 형식이다. 리스트와 마찬가지로 원소가 될 수 있는 값의 유형에는 제한이 없으며, 각각의 원소는 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을 사용한다면 이름? 이라고 입력하는 것으로 더 쉽게 확인이 가능하다. 

파이썬은 처음이라 – 내장함수는 거의 처음이라

우리는 지난 시간에 파이썬에서 함수를 정의하여 사용하는 방법에 대해서 살펴보았다. 함수라는 건 어떤 작은 일을 하는 단위에서 출발할 수 있고, 작은 일을 하는 함수들을 잘 조합하면 복잡한 IF나 WHILE문이 없더라도 조금 복잡하거나 멋진 일을 간단하게 처리할 수 있다는 것에 대한 일말의 가능성도 조금 엿보았다. 하지만 항상 모든 함수를 다 정의해서 사용해야 할 필요는 없다. 정말 자주 쓰이거나 일반적인 작업에 꼭 필수적이라서 매번 정의해야 하는 것이 좋을 것 같은 함수들에 대해서 대부분의 언어들은 기본함수라는 것들을 제공하고 있다.

사실 파이썬에는 엄청나게, 정말이지 엄청나게 많은 기본함수들이 있다. 하지만 이 많은 기본함수들이 모두 모든 상황에 필요한 것은 아니다. 기본함수들은 용도나 작업 목적등에 따라서 분류되어, 모듈이라는 단위로 묶여서 파이썬의 기본 라이브러리에 들어있다. 그리고 우리는 어떤 기능을하는 함수가 필요할 때, 그 함수가 있는 모듈을 라이브러리에서 찾아서 가져와 사용하면 된다. 물론 방대한 기본 라이브러리의 함수들을 모두 다 알고 있을 필요는 없고, 그 때 그 때 레퍼런스 문서등을 찾아서 사용하면 된다. 대신에 input()이나 print()와 같은 몇몇 중요한 기본적인 함수들은 별도의 모듈을 결합하지 않더라도 파이썬에서 기본적으로 쓸 수 있는데, 이러한 함수들을 빌트인함수, 즉 내장함수라 한다.

내장함수는 본 블로그의 내장함수일람을 참고해서 보면 된다. 대신 여기서는 지금까지 살펴봤던 몇 가지 내장함수를 살펴보고, 또 용도별로 종종 쓰게 될 것 같은 내장함수들을 간단히 소개하겠다. 역시 내장함수도 처음부터 모든 함수들을 다 외울 필요가 없다. 앞으로 파이썬을 공부해 나가다보면 자연스럽게 자주 써서 눈에 익는 함수들이 생길 것이고, 그러한 함수들 위주로 자연스럽게 익숙해지면 된다.

생성자

리스트, 문자열, 튜플은 각각 파이썬 내에서 (소문자로 시작한다.) list, str, tuple 이라는 타입 이름으로 불린다. 그리고 파이썬의 이런 기본타입들은 모두 타입이름과 동일한 생성자 함수를 갖고 있다.

  1. 생성자 함수는 인자 없이 호출되면 빈 연속열을 만든다.
  2. 생성자 함수는 다른 종류의 연속열 혹은 반복가능값을 해당 타입의 연속열로 변환한다.
## 빈 연속열을 만든다.
a = list() ## []
b = tuple() ## ()
c = str() ## ''

## range(5)은 반복가능한 값인데, 리스트나 튜플로 만들 수 있다.
a = list(range(5))
b = tuple(range(5))
#a -> [0,1,2,3,4], b->(0,1,2,3,4)
b = tuple(a)
#b -> (0,1,2,3,4)

## 단 str() 함수는 조금 특별한데, 주어진 값을 그대로 문자열로 바꾼다.
c = str(a)
## '[0, 1, 2, 3, 4]'
c = str(range(5))
## 'range(0, 5)'

참고로 사전(dict())과 세트(set())역시 생성자 함수를 가지고 있다. 이런 연속열이나 집합 외에 다른 생성자들도 있다. 문자열이나 실수를 정수값으로 바꾸는 int() 함수, 실수로 변경하는 float()함수가 이에 속한다.

기본 연산

기본연산에 사용되는 함수를 몇 가지 소개하겠다.

  • abs() : 절대값을 계산한다.
  • round() : 특정 자리에서 반올림한다.[^1]
  • divmod() : 주어진 두 수를 나눌 때 몫과 그 나머지를 계산한다.
## abs는 주어진 값의 절대값(부호가 없는 값)을 구한다.
abs(-7) # 7
abs(2) # 2

## round는 주어진 수를 주어진 자리에서 반올림한다. 
## 두 번째 인자를 써서 소수점 몇째자리까지 유지할 것인지 정할 수 있다.
round(23.2) # 23
round(23.2546, 3) # 23.255

## divmod(x, y)는 x를 y로 나눈 몫과 나머지를 계산한다.
## 이 때 몫을 Q, 나머지를 R 이라 할 때  Q*y +R = x 가 된다.
divmod(17, 5) # (3, 2)
q, r = divmod(-12, 5) 
# q = -3, r = 3

입출력

기본입출력에 사용되는 함수들이다. input()print() 함수에 대해서는 어느 정도 익숙해져 있을 것이다. open() 함수는 파일을 열어서 데이터를 입출력할 때 사용되는데, 파일 입출력에 대한 내용은 별도의 토픽으로 다뤄야 할 듯 하다.

  • input() : 키보드로 입력을 받는다.
  • print() : 화면에 글자를 출력한다.
  • open() : 파일을 연다. 이렇게 파일을 열어서 파일에 데이터를 저장하거나 불러올 수 있다.

연속열/반복 관련

실질적으로 가장 많이 쓰이고, 그래서 잘 알아두어야 한다.

  • sum() : 반복가능한 값에 대해 합계를 계산한다.
  • len() : 연속열의 길이를 구한다.
  • max(), min() : 반복가능한 값 중에서 최대, 최소 값을 구한다.
  • sorted() : 반복가능한 값의 원소들을 정렬하여 리스트로 만든다.
  • reversed() : 반복가능한 값의 원소들을 역순으로 나열하는 반복자를 만든다.
  • next() : 반복자의 다음 원소를 추출한다.
  • iter() : 반복가능한 값으로부터 반복자를 생성한다.
  • enumerate() : 반복가능한 값이 그 인덱스(0, 1, 2…)와 각 원소의 쌍을 반복할 수 있게 한다.
## sum
xs = [3, 1, 4, 2]
sum(xs) # -> 10
len(xs) # -> 4
min(xs) # -> 1
max(xs) # -> 4
## sorted의 경우에 정렬된 사본을 만든다.
sorted(xs) # -> [1, 2, 3, 4]  ## xs는 여전히 원래 순서를 유지한다.
list(reversed(xs)) # -> [2, 4, 1, 3]

for i, e  in enumerate(xs):
  print(i, e)
## 0 3
## 1 1
## 2 4
## 3 2

 

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

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

튜플 생성하는 법

튜플은 괄호를 사용한 튜플 리터럴을 써서 만들 수 있으며, 기본 내장함수인 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()의 인자 이름이 공교롭게도 전역 변수 이름과 같다는 것이다. 물론 함수 인자는 함수의 지역 변수로 취급되기 때문에 매번 호출될 때마다 안전하게 인자를 참조한다는 점은 변함이 없으나, 가능하면 인자의 이름이 전역 변수와 구분되도록 하는 것이 좋다.

 

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

파이썬은 처음이라 – 함수는 처음이라

함수는 일련의 동작을 수행하는 코드를 묶어서 재사용하기 쉽도록 호출가능한 단위로 만든 것을 말한다. 수학적인 관점에서 함수는 어떠한 입력이 주어졌을 때, 그 입력값을 사용하여 어떤 연산을 처리하고 다시 그 결과를 되돌려주는 계산상자에 비유되기도 한다. 파이썬의 함수는 이러한 두 특성을 모두 가지는 코드 덩어리이다.

함수의 수학적인 정의를 말로 풀어내지 않더라도 “계산상자”나 혹은 자판기 같은 것을 생각하면 함수가 어떤 역할을 하는지 쉽게 비유할 수 있다. 커피 자판기를 함수로 본다면 동전을 넣으면 커피가 나오는 함수에 비유할 수 있다. 물론 우리 모두는 커피 자판기 내부에 미리 넣어놓은 커피와 물 그리고 컵이 있고, 자판기에 동전을 넣으면 컵에 커피와 뜨거운 물이 담겨서 커피가 나온다는 사실은 알고 있다. 하지만 중요한 것은 동전을 넣으면 커피가 나온다는 사실, 즉 입출력에 대한 것이며, 함수에서 그 내부에 어떤 동작이 실제로 수행되는지는 사실 중요하지 않다.1

프로그램 역시 사용자로부터 정해진 형식의 데이터를 입력받아, 처리된 결과를 출력한다는 측면에서 하나의 함수라 볼 수 있다. 그리고 그 내부의 처리 과정에 있어서 입력값은 더 작은 단위의 함수에 의해서 중간값으로 변환되고, 다시 그 중간값이 또 다른 함수에 의해서 변환되는 과정을 거친 후 최종적으로 출력될 값이 된다. 2 즉 함수는 프로그래밍 코드를 재사용하는 단위인 동시에 프로그램을 구성하는 빌딩블럭(building block)으로도 볼 수 있다.

함수의 요소

사실 어떤 함수를 작성하는 것은 문법만 맞춘다면야 뭐 어떤 코드 블럭을 집어넣든 그것은 프로그래머의 마음이기 때문에 딱히 함수는 이렇게 정의해야 한다는 법칙 같은 것은 없다. 하지만 가능하다면 함수는 “어떤 일을 하는 단위”로 정의하고, 여기서 일은 앞서 설명한 바와 같이 입력을 받아 처리하고 출력값을 내놓는 범위로 정하면 된다. 따라서 함수의 정의는 함수가 갖추어야 할 요소들을 정의하는 것에서 출발한다.

  • 함수의 이름 : 함수의 이름은 여느 식별자와 마찬가지로 고유해야 한다.
  • 함수의 입력 : 함수의 입력은 함수가 실행될 때 전달받는 인자들이 된다. 인자는 하나 이상이거나, 경우에 따라서는 인자가 필요없는 경우도 있다.
  • 함수의 출력 : 함수가 어떤 값을 출력할지를 결정한다. 경우에 따라서 함수의 출력값이 없는 경우도 있을 수 있다. 이 경우에 함수는 암묵적으로 None을 리턴하게 된다.

함수 정의 문법

함수를 정의하는 문법은 다음과 같다.

def some_func( arg1, arg2 ):
^^^ ^^^^^^^^^ ^^^^^^^^^^^^ ^
 1    2         3          4
  5 블럭...
  6 return result
  1. 함수의 정의는 def 키워드로 시작한다.
  2. 함수의 이름을 선언하고
  3. 괄호 속에 함수가 받아들일 인자의 이름을 선언한다. 인자가 2개 이상인 경우 컴마로 구분하며, 인자가 필요없는 경우에는 빈 괄호를 사용한다.
  4. 선언부의 끝은 콜론으로 끝나며, 이는 그 다음줄 부터는 들여쓰기를 적용하는 블럭이란 의미이다.
  5. 함수가 실제로 처리할 코드 블럭을 작성한다.
  6. 함수의 끝은 return 문으로 끝난다. return 문은 함수가 리턴해야 할 값을 표현식으로 정의해준다. 리턴 값 없이 return 만 사용하면 return None으로 해석된다. 만약 return 문이 없는 함수는 함수의 마지막 줄에 암묵적으로 return None이 있는 것으로 간주한다.

간단한 함수를 정의하는 예를 몇가지 살펴보자.

## 두수의 합을 계산하는 함수
def add(x, y):
  return x + y

## 두수의 곱을 계산하는 함수
def mul(x, y):
  return x * y

## 정수 N을 입력 받아 1~N까지의 합을 계산하는 함수
## 단, N < 1 이면 0을 리턴한다. 
def accumlate(n):
  if n < 1:
    return 0 ## return 문을 만나면 함수의 실행은 여기서 종료된다.
  result = 0
  for i in range(n):
    result = reslut + i + 1
  return result

## 아래 함수는 메시지를 출력만 한다.
def print_tag(msg, tagname):
  print("<" + tagname + ">", msg, "</" + tagname + ">")
  ## 명시적인 리턴구문이 없으므로 여기까지 실행되면
  ## 함수의 실행이 종료된다. 

함수의 호출

함수의 내용을 실행하는 것을 함수를 호출한다고 표현한다. 함수의 호출은 함수의 이름 뒤에 괄호를 붙인다. 함수가 인자를 필요로 하는 경우에는 함수에 정의된 순서대로 인자를 콤마로 구분하여 넣어준다. 함수을 호출하는 문법은 그 자체로 하나의 표현식으로 취급되며, 이 표현식은 함수의 리턴값으로 평가된다. 우리는 이 글 이전에도 몇 가지 기본 함수를 사용하는 것을 예제를 통해 접해본 바 있다.

a = add(3, 4)
## a => 7
print(a)
## 7

## accumluate(10)은 그 자체로 결과값으로 평가되는 표현식이므로
## 다른 함수의 인자로 전달할 수 있다. 
print(accumlate(10))
## 55

print(int(input()) + 3)
## 입력된 숫자에 3을 더한 값을 출력한다. 

기본함수 input()은 키보드로부터 한줄의 문자열을 입력받는 함수이다. 따라서 input() 이라고 쓴 표현식은 키보드의 입력이 들어오게 되면 그 내용으로 구성된 문자열로 평가된다.

함수 호출과 흐름

파이썬 스크립트는 그 자체로 소스코드인 동시에 프로그램이기도 하다. 스크립트가 실행되면 소스코드의 맨 윗줄부터 파이썬 해석기에 의해서 실행되기 시작한다. 만약 어떠한 함수 호출도 사용하지 않는 프로그램을 작성했다면, 프로그램의 실행 방향은 코드의 위에서 아래로 흐르게 된다. 프로그램의 시작과 동시에 발생하는 실행 흐름을 메인 루틴(main routine)이라고 한다. 메인 루틴이 진행되는 과정에서 어떤 함수를 호출하게 되면 무슨 일이 생길까?

  1. 함수 호출 구문을 만나면, 현재 실행위치를 ‘어딘가’에 저장해두고 함수의 블럭 시작 위치로 실행 위치가 옮겨간다.
  2. 이 때, 전달된 인자값을 복사하여 가져가게 된다.
  3. 전달된 인자값은 함수 내에서 통용되는 변수가 되고, 이 값들을 이용해서 함수의 코드들이 실행된다.
  4. 리턴문을 만나거나 함수의 끝에 다다르면 1.에서 저장해두었던 위치로 돌아간다. 만약 리턴되는 값이 있다면 이 값을 가지고 가게된다.

즉, 함수를 호출하게 되면 메인 루틴이 잠시 중단되고 또 다른 별개의 실행 흐름이 시작된다. 이는 마치 고속도로의 1차선으로 달리다가, 함수를 호출하는 동안 2차선으로 차선을 변경한 후, 함수의 실행이 끝나면 다시 1차선으로 되돌아가는 것과 비슷하다고 하겠다. (물론 고속도로에서 차선을 바꾼다고, 원래 위치로 점프하는 것은 아니지만…) 만약 함수 내에서 다시 다른 함수를 호출한다면? 2차선에서 3차선으로, 3차선에서 4차선으로 계속해서 차선을 바꿔 “내려가게” 되고, 각 단계에서의 실행이 종료되면 다시 차선을 거슬러 올라가 1차선으로 돌아가게 된다. 따라서 메인 루틴을 중지하고 별개의 루틴으로 진입하게 된다는 점에서 함수의 실행 흐름을 서브 루틴(sub routine)이라고도 부른다.

메인루틴과 서브루틴은 절차지향적인 프로그래밍 관점에서의 비선형적인 실행 흐름을 이야기할 때 쓰는 표현이니, 그냥 그렇게 부르더라하는 정도로 이해하면 되겠다. 우리가 주목해야 할 점은 값 즉, 데이터이다. 입력 혹은 출력이 없는 몇몇 함수들을 예외적으로 둔다면, 데이터는 함수의 입력으로 들어가서, 함수의 내부에서 변환되어 출력으로 나오게 된다. 즉 함수는 그 외부에서 보았을 때 입력값을 변형하여 출력하는 장치로 볼 수 있다. 자판기라는 것을 전혀 본 적이 없는 사람의 입장에서 커피 자판기는 반짝거리는 쇠조각을 커피로 바꾸는 마법의 상자에 다름없듯이 말이다. 즉 “값을 조작하는 변환기”라는 관점에서 함수를 이해하고 있는 것이 앞으로 우리가 이야기하려는 관점에서는 매우 중요하다.

함수의 인자

함수의 인자를 정의하는 방법에 대해서 다시 생각해보자.

  1. 어떤 함수들은 인자를 받지 않은 경우가 있다.
  2. 어떤 함수들은 하나 혹은 그 이상의 고정된 인자를 받는다.
  3. 어떤 함수의 인자들은 있는 경우도 있고 없는 경우도 있다. (input(), print() 등)
  4. 어떤 함수들은 1개 이상의 정해지지 않은 개수의 인자를 받는다
  5. 어떤 함수들은 인자에 이름을 붙여야 하는 경우가 있다.

이중에서 함수의 인자가 고정된 경우는 앞서 소개한 문법을 사용해서 정의하는 것이 가능하다. 그렇다면 선택적 인자(있어도 되고 없어도 되는) 와 가변 인자(한 개 일수도, 여러 개 일 수도 있는)는 어떻게 정의할 수 있을까?

기본값을 갖는 인자

함수의 인자를 정의할 때, 디폴트 값을 정의할 수 있다. 인자에 디폴트 값을 정의하는 경우에는 호출하는 표현에서 해당 인자를 생략하면, 지정한 디폴트값을 사용한다.

def greet(name="unnamed"):
  print("hello, ", name)

greet('Tom')
# "hello, Tom"
greet()
# "hello, unnamed

위 함수에서와 같이 name 이라는 인자를 선언하면서 name="unnamed"라고 기본값을 지정해주었다. 이렇게 선언하면 해당 인자는 생략이 가능한 인자가 된다.  두 개 이상의 인자를 갖는 함수에서 일부 인자들만 기본값을 갖는다면, 기본값을 갖는 인자들을 항상 뒤쪽에 배치해야 한다. 왜냐하면 함수를 호출할 때, 인자값을 순서대로 넣기 때문이다.

## 동작하지 않는 예제!!
def some_func(a, b=1, c):
  return a + b + c

some_func(1, 2, 3) 
## a->1, b->2, c->3 임을 알 수 있다. 
some_func(1, 2)
## a->1 이지만 2는 b인가? c인가?

함수를 호출했을 때, 함수의 내부에서는 괄호안에 들어온 값들을 순서대로 매칭하려고 시도한다. 따라서 기본값이 없는 인자들을 구분할 수 있는 방법은 오로지 인자의 순서이다. 그렇기 때문에 인자 목록의 중간에는 디폴트 값을 갖는 인자를 넣을 수 없다. 파이썬에서는 이렇게 디폴트 값을 갖도록 선언한 인자를 ‘키워드 인자’라고 따로 구분해서 부른다. 그 이유는 다음의 가변인자에 대해 설명한 후에 풀어나가겠다.

가변 인자

두 개의 정수를 받아서 그 중에서 큰 값을 리턴하는 my_max()라는 함수를 정의한다고 생각해보자.

def my_max(a, b):
  if a > b:
    return a
  return b

함수 자체는 간단한데, 경우에 따라서는 3개의 값 중에서 가장 큰 값을 찾아야 하는 경우가 있을 것이다. 물론 그 때는 my_max(a, my_max(b, c))와 같은 식으로 b와 c중에서 큰 값을 찾고 그것을 다시 a와 비교해서 세 수 중의 최대값을 찾는 방법도 있을 것이다. 혹은 세 개의 수에 대해서 최대 값을 찾는 또 다른 함수를 정의해야 할 필요가 있을지도 모르겠다.

def my_max3(a, b, c):
  return my_max(a, my_max(b, c))

세 수 중에서 최대값을 구하는 구현에는 여러 가지가 있을 수 있다.

  1. 세 수 a, b, c 에 대해서 먼저 a > b  일 때, a  > c 이면 a가 최대값이고,  그렇지 않다면 c가 최대값이다. 다시 b >= a 일 때 b > c 이면 b 가 최대값이고 그렇지 않다면 c 가 최대값이다.
  2. 세 수 중에서 두 수의 최대값을 찾는다. 그리고 그 값과 나머지 한 값 중에서 최대값을 찾으면 그것이 세 수 중의 최대값이다.

위 두 명제는 세 수에 대해서 최대값을 찾는 방법을 설명한 글이다. 어떤 글이 더 간결하고 이해하기 쉬운가? 위 my_max3()은 두 번째 문장을 그래도 코드로 옮겨놓았으며, 그만큼 간결하고 실수를 통해서 버그가 발생할 여지도 줄였다. 이것이 함수로 함수를 만드는 관점이 가지는 힘이다.

그런데, 그러다보면 4 개, 5개, 6개의 수에 대해서 최대값을 찾아야 하는 경우도 빈번하게 발생할 수 있을 수 있고, 그 때마다 인자를 달리하는 다른 함수들을 매번 작성하기는 번거롭다. 파이썬에서는 이렇게 인자의 개수가 정해지지 않은 함수를 정의하는 방법이 있다.

def some_func(*args):  #1
  pass

def my_max_n(a, b, *cs):  #2
  ...

바로 인자의 이름 앞에 *3을 붙이는 것이다. 이렇게하면 some_func(1, 2), some_func(1, 2, 3), some_func(1, 2, 3, 4)와 같이 인자를 얼마든지 많이 넣을 수 있다. 그리고 각각의 인자는 함수의 내부에서 리스트와 비슷하게 args[0], args[1],.. 과 같은 식으로 참조할 수 있다.

두 번째 my_max_n(a, b, *cs) 의 의미는 a와 b는 반드시 필수적으로 넣어야 하는 인자이며, 그 이후 자리는 가변인자들로 넣어도 그만, 안넣어도 그만인 셈이다. (실제 my_max_n() 함수의 구현에 관해서는 튜플에 대한 내용을 배운 다음에 설명하는 것이 좋을 것 같다.)

언패킹

가변 인자는 선언하고자 하는 경우에 고정 인자와 키워드 인자(방금 말했던 디폴트 값이 있는 인자) 사이에 위치해야 한다. 키워드 인자에 대해서도 모든 기본값을 정의하기 어렵거나, 특정한 환경 설정과 관련된 함수의 경우에 인자가 수십개가 넘어가는 경우가 있어서 일일이 인자를 정의하기 힘든 경우가 있다. 이 때는 가변 키워드 인자를 정의할 수 있는데, **변수명으로 선언할 수 있다. 이렇게 선언된 가변 키워드 인자는 함수 내에서  나중에 배우게 될 사전으로 취급된다.

정리하자면 인자의 정의 순서는 고정인자 > 가변인자 > 키워드인자 > 가변키워드인자 의 순으로 정의해주면 된다.

보너스 – 반환값이 2개인 함수

C와 같은 언어를 먼저 접해본 경험이 있는 사람이라면 이 지점이 상당히 당황스러울 수 있는데, 파이썬에서 함수는 하나의 리턴값만을 반환하는 것이 아니라, 2개 혹은 그 이상의 값을 한꺼번에 반환하는 것이 가능하다. 바로 가변 인자에서 잠깐 언급한 튜플(tuple)을 사용하는 방법이다. 표준 내장함수 중에서 divmod()라는 함수가 있는데, 이 함수는 두 수를 받아서 나눈 몫과 그 때의 나머지를 계산해서 한 번에 리턴한다. 예를 들면 다음과 같은 식이다.

def divmod(x, y):
  return x // y, x % y

리턴해야 하는 값이 2개 이상이어야 하는 경우는 언뜻 생각하기에 엄청 예외적일 수 있다고 생각되겠지만, “그 이전에 항상 다른 방법으로 해결해왔기 때문에” 그렇게 느끼는 것일 뿐, 이 패턴이 유용한 경우는 생각보다 매우 많다.

이렇게 해서 기본적으로 함수를 정의하고 호출하는 방법에 대해서 살펴보았다. 앞으로 많은 예제들은 함수를 정의하고 함수와 함수를 연계하는 식으로 문제를 해결해나가는 방법을 소개할 것이기 때문에 함수와 관련된 문법은 자연스럽게 익숙해질 것으로 생각된다. 또한 그러한 접근 방식을 통해서 보다 분명하고 간결하게 문제를 해결하는 힘을 기를 수 있기를 기대해 본다.


  1. 이것은 일종의 추상화이다. 어떤 함수에 대해서 입력의 형식과 출력의 형식이 정해져 있다면, 실제 함수의 구현은 함수외부의 입장에서는 관심사가 아니다. 예를 들어 자연수 N을 입력으로 주면 1~N까지의 자연수의 합을 계산하는 함수가 있다고 가정해보자. 여기서 중요한 것은 10을 넣으면 55가 계산되어 나온다는 점이며, 그 내부의 구현이 루프를 돌면서 누적값을 더해나가든, 삼각수 공식을 사용하여 계산하든하는 점은 함수를 사용하는 입장에서는 몰라도 된다는 점이다. 이것은 반대로 함수를 구현하는 입장에서도 중요한데, 입력과 출력의 형식만 똑같이 유지한다면 함수 내부의 구현 코드를 어떻게 바꾸든 그것은 그 함수를 사용하는 부분의 전체 코드를 변경할 필요가 생기지 않는다는 점이다. 결국 함수에서 그 내부와 외부가 공유해야 하는 정보는 함수의 입력과 출력의 형식이다. 
  2. 수학에서 함수를 합성하여 제 3의 함수를 만들 수 있는 것처럼, 프로그램을 함수로 보는 이 관점에서 결국 프로그램은 프로그램 내부에 정의된 함수들을 정교하게 합성한 합성함수로 간주할 수 있다. 
  3. 이렇게 변수 이름앞에 *가 붙은 것을 언팩 연산자라고 하며, 이는 컴마로 쓰여진 일련의 값들을 튜플로 바인딩하는 연산자이다.