숫자리스트를 정렬할 때 특정 그룹의 숫자들이 먼저 오도록 우선순위를 매기는 코드를 작성한다고 가정합니다. 이런 패턴은 사용자 인터페이스를 표현하거나 다른 것보다 중요한 메시지나 예외 이벤트를 먼저 보여줘야 할 때 유용합니다.

일반적인 방법은 리스트의 sort 메서드에 헬퍼함수를 key 인수로 넘기는 것입니다. 

def sort_priority(values, group):
    def helper(x):
        if x in group:
            return (0, x)
        return (1, x)
    values.sort(key=helper)
numbers = [8, 3, 1, 2, 5, 4, 7, 6]
group = {2, 3, 5, 7}
sort_priority(numbers, group)
print(numbers)
# 결과
# [2, 3, 5, 7, 1, 4, 6, 8]

함수가 예상대로 동작한 이유는 세 가지 입니다.

  1. 파이썬은 클로저(closure)를 지원
    1. 클로저란 자신이 정의된 스코프에 있는 변수를 참조하는 함수입니다. 이러한 점때문에 helper 함수가 sort_priority의 group 인수에 접근할 수 있습니다
  2. 함수는 파이썬에서 일급 객체(first-class object)
    1. 함수를 직접 참조하고 변수에 할당하고, 다른 함수의 인수로 전달하고 표현식과 if문 등에서 비교할 수 있다는 의미입니다. 따라서 sort 메서드에서 클로저 함수를 key 인수로 받을 수 있습니다.
  3. 파이썬에는 튜플을 비교하는 특정 규칙 존재
    1. 먼저 인덱스 0으로 아이템을 비교하고 그 다음으로 인덱스 1, 다음은 인덱스 2와 같이 진행합니다. helper 클로저의 반환 값이 정렬 순서를 분리된 두 그룹으로 나뉘게 한 건 이 규칙때문입니다.


함수에서 우선순위가 높은 아이템을 발견했는지 여부를 반환해서 사용자 인터페이스 코드가 그에 따라 동작하게 하면 좋습니다. 이런 동작을 추가하는 일은 쉽습니다. 이미 각 숫자가 어느 그룹에 포함되어 있는지 판별하는 클로즈 함수가 있습니다. 우선순위가 높은 아이템을 발견했을 때 플래그를 뒤집는 데 클로저를 사용하는건 어떨까요? 함수는 클로저가 수정한 플래그 값을 반환할 수 있게 됩니다.

def sort_priority2(numbers, group):
    found = False
    def helper(x):
        if x in group:
            found = True # 간단해보임
            return (0, x)
        return (1, x)
    numbers.sort(key=helper)
    return found


numbers = [8, 3, 1, 2, 5, 4, 7, 6]
group = {2, 3, 5, 7}
 
found = sort_priority2(numbers, group)
print('Found: ', found)
print(numbers)

# 결과
# ('Found: ', False)
# [2, 3, 5, 7, 1, 4, 6, 8]


정렬된 결과는 올바르지만 found 결과는 틀린 것을 확인할 수 있습니다. 표현식에서 변수를 참조할 때 파이썬 인터프리터는 참조를 해결하려고 다음과 같은 순서로 스코프 (scope: 유효범위)를 탐색합니다.

  1. 현재 함수의 스코프
  2. (현재 스코프를 담고 있는 다른 함수 같은) 감싸고 있는 스코프
  3. 코드를 포함하고 있는 모듈의 스코프 (전역 스코프라고도 함)
  4. (len이나 str 같은 함수를 담고 있는) 내장 스코프

이 중 어느 스코프에도 참조한 이름으로 된 변수가 정의되어 있지 않으면 NameError 예외가 일어납니다.


변수에 값을 할당할 때는 다른 방식으로 동작합니다. 변수가 이미 현재 스코프에 정의디어 있다면 새로운 값을 얻습니다. 파이썬은 변수가 현재 스코프에 존재하지 않으면 변수 정의로 취급합니다. 새로 정의되는 변수의 스코프는 그 할당을 포함하고 있는 함수가 됩니다.


위 코드에서 found 변수는 helper 클로저에서 True로 할당됩니다. 클로저 할당은 sort_priority2에서 일어나는 할당이 아닌 helper 안에서 일어나는 새 변수 정의로 처리됩니다.

def sort_priority2(numbers, group):
    found = False # 스코프: sort_priority2
    def helper(x):
        if x in group:
            found = True # 스코프 : helper -- 안좋음
            return (0, x)
        return (1, x)
    numbers.sort(key=helper)
    return found

이 문제는 초보자들을 매우 놀라게 한다고 해서 스코프 버그(scoping bug)라고도 하지만 언어 설계자가 의도한 결과입니다. 이 동작은 함수의 지역 변수가 자신을 포함하는 모듈을 오염시키는 문제를 막아줍니다. 그렇지 않았다면 함수안에서 일어나는 모든 할당이 전역 모듈 스코프에 쓰레기를 넣는 결과로 이어졌을 것입니다. 

데이터 얻어오기

파이썬 3에는 클로저에서 데이터를 얻어오는 특별한 문법이 있습니다. nonlocal문은 특정 변수 이름에 할당할 때 스코프 탐색이 일어나야 함을 나타냅니다. 유일한 제약은 nonlocal이 (전역 변수의 오염을 피하려고) 모듈 수준 스코프까지 탐색할 수 없다는 점입니다. 

다음은 nonlocal을 사용하여 같은 함수를 다시 정의한 예입니다.

def sort_priority3(numbers, group):
    found = False 
    def helper(x):
        nonlocal found
        if x in group:
            found = True
            return (0, x)
        return (1, x)
    numbers.sort(key=helper)
    return found

nonlocal 문은 클로저에서 데이터를 다른 스코프에 할당하는 시점을 알아보기 쉽게 해줍니다. nonlocal 문은 변수 할당이 모듈 스코프에 직접 들어가게 하는 global문을 보완합니다.


하지만 전역변수의 안티패턴(anti-pattern)과 마찬가지로 간단한 함수이외에는 nonlocal을 사용하지 않도록 주의해야합니다. nonlocal의 부작용은 알아내기가 어렵기 때문입니다. 특히 nonlocal 문과 관련 변수에 대한 할당이 멀리 떨어진 긴 함수에서는 이해하기가 더욱 어렵습니다.


nonlocal을 사용할 때 복잡해지기 시작하면 헬퍼 클래스로 상태를 감싸는 방법을 이용하는 것이 낫습니다. 이제 nonlocal을 사용할 떄와 같은 결과를 얻는 클래스를 정의하겠습니다. 코드는 더 길지만 이해하기는 훨씬 쉽습니다. 

class Sorter(object):
    def __init__(self, group):
        self.group = group
        self.foudn = False
    def __call__(self, x):
        if x in self.group:
            self.found = True
            return (0, x)
        return (1, x)

numbers = [8, 3, 1, 2, 5, 4, 7, 6]
group = {2, 3, 5, 7}


sorter = Sorter(group)
numbers.sort(key=sorter)
assert sorter.found is True

요약

클로저 함수는 자신이 정의된 스코프 중 어디에 있는 변수도 참조할 수 있음

기본적으로 클로저에서 변수를 할당하면 바깥쪽 스코프에는 영향이 미치지 않음

파이썬 3에서는 nonlocal문을 사용하여 클로저를 감싸고 있는 스코프의 변수를 수정할 수 있음을 알림

간단한 함수 이외에는 nonlocal 문을 사용하지 않는 것을 권고