끝나지 않는 프로그래밍 일기

이번 강좌는 클로저(Closure)에 대해 알아보도록 하겠습니다. 클로저는 위키백과의 정의를 빌어온자면 '컴퓨터 언어에서 클로저는 일급 객체 함수의 개념을 이용하여 스코프에 묶인 변수를 바인딩 하기 위한 일종의 기술이다.'라고 합니다. 파이썬을 처음 접하시는 분이라면 이 말이 무슨 말인지 전혀 감도 오지 않을 것입니다. 먼저 우리는 일급 객체 함수가 무엇을 말하는지 알아볼 필요가 있습니다.

 

일급 객체(First-class function)

파이썬에서 함수는 일급 객체입니다. 이는 우리가 평소에 숫자나 문자열, 클래스를 다루는 것처럼, 함수도 다른 객체와 동일하게 취급할 수 있다는 말과 같습니다. 즉, 함수를 매개변수로 넘기거나 다른 변수에 대입할 수 있으며, 반환값으로도 사용이 가능합니다. 심지어 리스트나 사전과 같은 자료구조에 저장할 수도 있습니다. 

>>> def callf(func):
	return func()

>>> def say_hi():
	return "안녕"

>>> callf(say_hi)
'안녕'

callf 함수는 매개변수로 넘어온 함수를 호출하는 역할을 수행하고, say_hi는 그저 "안녕"이란 문자열을 반환하는 역할을 가지고 있습니다. callf(say_hi)는 say_hi()를 반환하고 이는 결국 say_hi의 반환값을 돌려줍니다. 

def add(a, b):
    return a + b

def substract(a, b):
    return a - b

def multiply(a, b):
    return a * b

def divide(a, b):
    return a / b

func_lst = [add, substract, multiply, divide]
a = int(input("a의 값을 입력하세요: "))
b = int(input("b의 값을 입력하세요: "))
for func in func_lst:
    print(func.__name__, ":", func(a, b))

결과:

a의 값을 입력하세요: 3

b의 값을 입력하세요: 4

add : 7

substract : -1

multiply : 12

divide : 0.75

위 예제와 같이 함수를 리스트에 넣어서 반복문으로 한꺼번에 처리가 가능합니다.

 

중첩 함수(Nested function)

클로저를 이해하기 위해서는 하나 더 알고 넘어가야 합니다. 중첩 함수 또는 내부 함수(Inner function)는 말 그대로 함수 내에 또 다른 함수가 있는 것을 말합니다. 아래와 같이 말이죠.

>>> def outer():
	print("여긴 외부 함수 영역입니다.")
	def inner():
		print("여긴 내부 함수 영역입니다.")
	inner()
	
>>> outer()
여긴 외부 함수 영역입니다.
여긴 내부 함수 영역입니다.

outer 함수 내에서는 inner 함수를 호출할 수 있는데, outer 함수의 외부에서는 내부 함수인 inner를 호출할 수 있을까요?

>>> inner()
Traceback (most recent call last):
  File "<pyshell#82>", line 1, in <module>
    inner()
NameError: name 'inner' is not defined

이름 inner이 정의되지 않았다는 에러가 발생합니다. 이는 outer 함수 내에서 선언되었으니, outer 함수 내에서만 호출이 가능하다는 것을 의미합니다. 

 

클로저(Closure)

이젠 정말 클로저가 무엇인지에 대해 살펴보겠습니다. 클로저의 정의는 이미 보았으나 아직도 감이 잡히질 않습니다. 예제를 하나 더 보도록 하겠습니다.

>>> def start_at(x):
	def increment_by(y):
		return x + y
	return increment_by

>>> closure1 = start_at(1)
>>> closure2 = start_at(2)
>>> print("closure1:", closure1(3))
closure1: 4
>>> print("closure2:", closure2(4))
closure2: 6

함수 start_at을 살펴보시면 내부 함수인 increment_by를 반환하고 있습니다. 이는 우리가 앞서 배운 일급 객체의 성질을 생각해보면 이해가 되는 부분입니다.

>>> closure1 = start_at(1)
>>> closure2 = start_at(2)

이 부분을 보니 외부 함수인 start_at에 매개변수 x로 1과 2를 넘겼습니다. 그리고 closure1과 closure2에는 함수 객체가 담긴 것을 예상할 수 있습니다. 여기서 "외부 함수인 start_at의 역할이 끝남과 동시에 매개변수 x의 값도 메모리상에서 사라지는게 아닐까?"라고 생각해볼 수 있겠습니다. 그런데 이게 왠걸 내부 함수인 increment_by는 외부 함수에서 사용되는 변수 x의 값을 그대로 기억하고 있는 것을 볼 수 있습니다.

>>> print("closure1:", closure1(3)) # = start_at(1)(3)
closure1: 4
>>> print("closure2:", closure2(4)) # = start_at(2)(4)
closure2: 6

이러한 행동을 하는 함수를 클로저라고 부릅니다. 클로저는 내부 함수가 메모리에 존재하지 않는 경우에도, 호출될 때 주변 환경을 기억합니다. 외부 함수의 start_at과 함께 매개변수 x도 사라졌으나, 내부 함수에 쓰인 x에는 우리가 호출할 때 외부 함수로 넘겨줬던 값이 묶인다는 것을 확인했습니다. 

>>> closure1.__closure__ # closure1 = start_at(1)
(<cell at 0x0415AFB0: int object at 0x63F464B0>,)
>>> closure2.__closure__ # closure2 = start_at(2)
(<cell at 0x0415AEB0: int object at 0x63F464C0>,)
>>> closure1.__closure__[0].cell_contents
1
>>> closure2.__closure__[0].cell_contents
2

위와 같이 __closure__ 멤버로 직접 확인해볼 수 있습니다. __closure__는 셀로 이루어진 튜플이며, 각 셀에는 cell_contents라는 멤버가 있는데 이를 통해 셀에 담긴 값을 확인해볼 수 있습니다. 파이썬에서 셀(cell) 객체는 클로저의 자유 변수(free variables)를 저장하기 위해 사용됩니다. 여기서 자유 변수는 '코드 영역에서 사용되지만, 전역 변수도 아니며 그 영역 내에서도 정의하지 않는 변수'를 말합니다. 

a = 1
def outer():
    b = 2
    def inner():
        c = 3
        print(a, b, c)
    inner()

위의 예제에서는 함수 inner 기준으로 b가 자유 변수라고 말할 수 있습니다. inner의 코드 영역에서는 a는 전역 변수이며, c는 지역 변수인데 변수 b는 함수 inner 내부에서 정의된 것이 아니기 때문입니다. 

 

다시 살펴보는 스코핑 룰(Scoping rule)

파이썬의 함수 강좌에서 스코핑 룰을 살펴본 적이 있습니다. 이 강좌에서는 혼란을 피하기 위해 LGB 규칙이라고 소개했으나, 클로저까지 살펴본 지금은 스코핑 룰을 아래처럼 확장할 수 있습니다.

LEGB(Local, Enclosed, Global, Built-in) 규칙

방금 보았던 예제를 기준으로 스코핑 룰을 다시 한번 살펴보도록 하겠습니다.

a = 1 # global
def outer():
    b = 2 # outer 함수 기준으로는 local, inner 함수 기준으로는 enclosed
    def inner():
        c = 3 # inner 함수 기준으로는 local
        print(a, b, c)
    inner()

내장(built-in), 전역(global)과 지역(local)은 이미 잘 알고 계시리라 생각합니다. 이 세 유효 범위가 헷갈리신다면 함수 강좌의 스코핑 룰을 다시 한번 살펴보시기 바랍니다. 새로 등장한 영역은 둘러싸인 범위(enclosing scope)로, 자신을 둘러싸는 상위 범위를 말하는 것입니다. 이 범위는 중첩 함수나 람다에서 나타나는데, 자신을 둘러싸는 상위 범위는 내부 함수 기준으로 외부 함수의 범위를 말한다고 볼 수 있습니다. 위 예제의 주석을 보시면 한 눈에 바로 확인하실 수 있습니다.

 

nonlocal

마지막으로 중첩 함수의 예제를 한번 다시 살펴보고 마치도록 하겠습니다. 중첩 함수의 관계에서, 내부 함수가 외부 함수의 지역 변수의 값을 다시 할당하려고 하면 어떻게 해야 할까요? 아래와 같이 작성하면 될 것 같기도 합니다. 

def outer():
    a = 10
    def inner():
        a += 10
        print('a:', a)
    inner()

outer()

위와 같이 내부 함수인 inner 입장에서 전역도 지역도 아닌 a에 값을 다시 할당하려 했더니 아래와 같은 오류를 볼 수 있습니다.

UnboundLocalError: local variable 'a' referenced before assignment

할당하기 전에 지역 변수 'a'가 참조되었다는 에러입니다. 내부 함수에서 외부 함수의 지역 변수인 a를 못찾고 있는 것으로 보입니다. 이러한 문제를 해결하기 위해서 a를 아래와 같이 nonlocal로 선언하면 됩니다.

def outer():
    a = 10
    def inner():
        nonlocal a
        a += 10
        print('a:', a)
    inner()

outer()

위와 같이 nonlocal로 선언하면 가장 가까이서 둘러싸는 함수 영역에서 주어진 이름을 찾습니다. 위의 결과를 확인해보시면 a의 값이 10에서 20으로 바뀐 것을 확인하실 수 있습니다.