Python 강좌 / 변수의 범위와 변경 가능한 객체의 동작 이해하기

변수의 범위는 이름을 어디에서 찾을지 정하고, 객체의 변경 가능성은 같은 값을 가리키는 이름 사이에 어떤 변화가 보일지 정합니다. 대입과 객체 변경을 구분하면 함수 호출 뒤 데이터가 뜻밖에 바뀌는 원인을 이해할 수 있습니다.

지역 이름과 외부 이름

함수 안에서 대입하는 이름은 별도 선언이 없다면 지역 변수로 취급됩니다. 함수에서 외부 값을 읽는 것과 외부 이름에 새 값을 대입하는 것은 다릅니다. 전역 변수에 의존하기보다 필요한 값을 인수로 받고 결과를 반환하면 테스트하기 쉽습니다.

관련 동작과 지원 조건은 Python: classes에서 확인할 수 있습니다.

global은 모듈 수준 이름, nonlocal은 바깥 함수의 이름 연결을 바꿀 때 사용합니다. 모든 상태 문제를 이 선언으로 해결하려 하지 말고 데이터 전달 구조를 먼저 검토합니다.

함수 기본값과 인스턴스의 공유 상태은 위치·키워드 인수와 기본값 사용하기 및 생성자·인스턴스 속성·클래스 속성 이해하기에서 사용하는 규칙과 연결됩니다.

전체 예제와 실행 결과

별도의 실습 폴더에서 아래 파일을 UTF-8로 저장합니다. 가상환경을 선택한 터미널에서 python scope.py 명령으로 실행합니다. macOS·Ubuntu에서 가상환경을 사용하지 않는 경우 명령 이름을 python3으로 바꿉니다.

scope.py 파일의 전체 내용입니다.

def add_fee(items, fee):
    items.append(fee)

original = [3500, 8000]
alias = original
copy = original.copy()
add_fee(alias, 500)
print(original)
print(copy)

nested = [[1], [2]]
shallow = nested.copy()
shallow[0].append(9)
print(nested)

예상 결과는 다음과 같습니다.

[3500, 8000, 500]
[3500, 8000]
[[1, 9], [2]]

대입·얕은 복사·깊은 복사

alias = original은 같은 리스트를 가리키는 이름을 하나 더 만듭니다. copy()는 바깥 리스트를 새로 만들지만 내부 항목의 객체는 공유하는 얕은 복사입니다. 중첩 예제에서 안쪽 리스트가 같이 바뀌는 이유입니다.

copy.deepcopy는 내부 구조를 재귀적으로 복사하는 데 사용할 수 있으나 파일·연결·공유가 필요한 객체까지 무조건 독립 복제하는 만능 해결책은 아닙니다. 변경해야 하는 범위만 새로 구성하거나 불변 데이터 구조를 선택하는 편이 계약을 명확히 할 수 있습니다.

함수 안의 이름은 일반적으로 Local(지역), Enclosing(바깥 함수), Global(모듈), Builtins(내장)의 순서로 찾습니다. 함수 안에 대입이 있으면 global·nonlocal 선언이 없는 그 이름은 지역 이름으로 취급하므로, 대입 전 읽기는 UnboundLocalError가 됩니다. 재대입은 이름의 연결을 바꾸지만 append는 이미 연결된 리스트 객체를 바꿉니다. 다음 실험의 value는 지역에서 20으로 바뀌어도 모듈의 10은 유지되고, nonlocal count는 바깥 함수의 셀을 갱신합니다.

아래 전체 코드를 scope.py로 저장하고 같은 실습 폴더에서 python scope.py로 실행합니다.

value = 10
def local_read():
    value = 20
    return value
def broken():
    value += 1
    return value
print(local_read(), value)
try:
    broken()
except UnboundLocalError:
    print("local name not initialized")
def make_counter():
    count = 0
    def increment():
        nonlocal count
        count += 1
        return count
    return increment
counter = make_counter()
print(counter(), counter())

예상 결과:

20 10
local name not initialized
1 2

이 동작과 적용 조건의 공식 설명: Python: resolution of names

함수 계약에 변경 여부 드러내기

함수가 입력 리스트를 바꾸는지 문서와 이름에서 알 수 있게 합니다. 원본을 유지하는 함수라면 새 리스트를 반환하는 구현으로 바꿀 수 있습니다. 결과와 부수 효과를 모두 시험하지 않으면 반환값이 맞아도 원본 손상을 놓칠 수 있습니다.

  1. add_fee(copy, 100)을 호출해 original에는 100이 추가되지 않는지 확인합니다.
  2. 함수 안의 append를 items = items + [fee]로 바꾸면 외부 리스트가 그대로인지 확인합니다.
  3. 원래 코드로 되돌리고 바깥 객체와 내부 객체의 공유를 각각 설명합니다.