4부 객체지향 상용구 - 8. 객체 참조, 가변성, 재활용

seokj·2023년 2월 28일
0

FluentPython

목록 보기
9/9

객체를 잘 다루기 위해서는 객체가 언제 생성되고 소멸되는지, 어떤 객체가 공유되는지를 알아야 하고, 메모리를 절약하거나 오류없이 돌아가는 코드를 짤 수 있다.


중요 키워드
별명
id, is
얕은 복사, 깊은 복사, copy.copy, copy.deepcopy, __copy__, __deepcopy__
call by sharing
garbage collection, refcount
약한 참조, weakref.ref, weakref.finalize
인터닝

덜 중요한 키워드
순환 참조, 세대별 가비지 컬렉션 알고리즘(generational garbage collection algorithm), 데드락
weakref.WeakValueDictionary, weakref.WeakKeyDictionary, weakref.WeakSet, 디스크립터

참고 자료
http://www.pythontutor.com/
http://bit.ly/1GsWTa7
https://docs.python.org/3/library/weakref.html
https://stackoverflow.com/questions/75595863/is-interning-associated-with-indentation-in-python
http://bit.ly/1GsZwss
http://bit.ly/1GsZvEO
http://bit.ly/1HGCayS
http://pymotw.com
http://bit.ly/py-libex
http://bit.ly/1HGCbmj
http://bit.ly/1FSDBpM
http://bit.ly/1HGCduC
http://bit.ly/1Gt0HrJ


변수를 상자로 비유하여 상자에다 값을 넣는 것으로 비유를 많이 하곤 하지만 파이썬에서는 모든 것이 객체이므로 변수가 어떤 객체에 할당되었다고 해서 객체가 그 변수에 종속된 것이 아니다. 변수 할당문은 객체를 생성한 뒤 그 객체를 가리키는 별명을 짓는 것으로 받아들이는 게 더 낫다. 실제로도 그 객체의 별명(alias)라 한다. 아래 코드는 int 객체 3을 생성한 뒤 a와 b가 같은 그 객체를 가리킨다. 같은 객체이므로 is연산의 결과가 True가 되고 id가 같다.

a = 3
b = a
assert a is b
assert id(a) == id(b)

같은 객체를 가리키는 두 변수 a, b는 정체성이 같다고 한다. 다른 객체를 가리킨다면 정체성이 다르지만, 각각의 객체가 가진 값이 같을 수는 있다. 이 경우 정체성을 검사하는 is연산의 결과는 False가 되고 ==로 비교한 결과는 True가 된다.

파이썬의 모든 객체는 정체성, 자료형, 값을 가진다.

id함수를 통해 그 함수의 id를 숫자형태로 얻을 수 있는데, ==연산을 통해 두 객체의 정체성을 비교할 수 있다. 하지만 is는 오버로딩 할 수 없는 연산이라 특별메서드를 호출할 필요가 없어서 ==보다 빠르기 때문에 id를 통한 비교 대신 is연산을 통해 정체성을 비교한다. ==연산자는 __eq__특별 메서드를 호출한다.

정체성 비교가 아닌, 값을 비교하는 경우에는 ==연산을 사용한다. 단, 이 경우에도 변수가 싱글톤 객체라면 is를 통해 비교하는 게 좋다. None과 비교할 때도 is로 비교한다.

튜플이 불변형인 것은 항목의 추가/삭제/변경이 불가능하다는 것인데 이때 '변경'이라는 것이 튜플이 가리키는 객체를 바꿀 수 없다는 뜻이다. 가리키는 객체가 가변 객체여서 변형이 일어나는 것은 허용된다. 튜플이 가변객체인 list를 가리키고 있을 때 list에 항목을 추가/삭제/변경하는 것은 허용된다.


복사에는 얕은 복사와 깊은 복사가 있다. 복사하려는 객체의 생성자를 사용하거나 시퀀스의 경우 [:]를 이용하거나 copy모듈의 copy함수를 사용하면 얕은 복사를 할 수 있다. 해당 객체는 복사가 일어나지만 해당 객체의 항목이 일일이 복사되지는 않는다. 항목이 모두 불변형이라면 메모리가 절약되면서 아무 문제가 발생하지 않겠지만 가변형이 있는 경우에는 같은 객체를 공유하므로 변경 내용이 모두에게 적용된다는 문제가 발생한다. 이런 경우 항목 하나하나를 복사하는, 깊은 복사를 해야한다. 깊은 복사는 copy모듈의 deepcopy함수를 사용한다.

깊은 복사를 할 때 자칫하다 싱글톤 객체를 복사해버리거나 크기가 큰 데이터베이스를 복사해버리는 등 너무 깊게 복사하여 문제가 발생할 수 있다. 이 경우 __deepcopy__함수를 오버라이딩하여 해결할 수 있다. 얕은 복사도 __copy__함수를 오버라이딩하여 변경할 수 있다.

깊은 복사는 구현하기 까다롭다. 아래 코드의 경우가 문제인데, list의 항목으로 자기 자신이 들어간 형태이다. 순환 참조가 일어나고 있어서 깊은 복사 시 무한 재귀에 빠질 수 있다. 그렇기 때문에 파이썬에서는 이미 복사한 객체를 메모해놓고 복사되지 않은 객체만 복사하는 방식으로 구현하였다.

a = []
a.append(a)

파이썬 함수의 파라미터 전달방식은 call by sharing이다. 이 방식은 파라미터로 전달되는 과정에서 정체성이 바뀌지 않는다. 그래서 가변객체인 경우 함수 내부에서의 변경사항이 바깥 객체에도 적용된다. 불변객체인 경우 변경할 수 없으므로 바깥 객체가 변경될 우려가 없다. 함수 내부에서 매개변수에 할당연산을 하면 그 매개변수와 같은 이름을 가진 새로운 변수가 될 뿐, 바깥 객체가 변경되지 않는다.

튜플의 경우 += 연산은 =와 +로 풀려 적용되므로 함수 안에서 매개변수로 받아 연산하더라도 바깥의 객체가 변경되지 않는다. 하지만 리스트는 +=이 =와 +로 풀려 적용되지 않는다. 가변객체임을 활용하여 +=연산자에 오버라이딩이 되어있기 때문이다.

함수 매개변수의 기본값은 매번 함수가 호출될 때마다 생성될 수 있는게 아니라 함수에 귀속된 것이다. 기본값으로 가변객체가 있을 경우 기본값을 통해 어떤 작업을 수행하다가 변경된다면 그 함수 자체의 매개변수의 기본값이 변경된다. 아래 코드를 통해 확인할 수 있다.

def append_2(a=[]):
    a.append(2)
    return a
print(append_2.__defaults__)
print(append_2())
print(append_2())
print(append_2())
print(append_2.__defaults__)

그래서 함수의 매개변수 기본값으로 가변형 객체로 설정하지 않는다. 일반적으로 기본값을 None으로 한 다음 함수 몸체에서 None일 경우 해당 객체를 생성해주는 식으로 한다.

함수 안에서 일련의 과정을 수행하는 동안 바깥의 객체가 바뀌지 않아야 하는지, 바뀌어야 하는지를 잘 체크해서 복사를 안 할지, 얕은 복사를 할지, 깊은 복사를 할지를 생각하는 것이 좋다. 일반적으로 클래스의 생성자로 리스트를 받는다면 생성자를 통한 얕은 복사를 하여 시퀀스 형을 보장시키면서 바깥의 객체에 영향을 주지 않는 방식을 많이 사용한다.


del명령은 해당 이름을 제거하는 것일 뿐 객체를 제거하는 것이 아니다. 객체는 가비지콜렉터가 제거한다. 가비지콜렉터는 파이썬의 구현에 따라 다르지만 CPython의 경우 refcount를 세어 0이 되는 경우 객체를 제거하는 방식으로 작동한다. refcount는 그 객체가 가진 별명의 개수로, 별명이 생성되거나 제거될 때마다 증감시켜 계산한다.

접근이 불가능한 둘 이상의 객체가 서로서로를 참조하여 순환참조를 이루고 있다면 refcount는 0이지만 접근은 할 수 없는 상태로 가비지콜렉터가 제거해야 하는 대상이지만 refcount를 세는 방식으로는 탐지하지 못한다. 그래서 CPython은 세대별 가비지 콜렉션 알고리즘(generational garbage collection algorithm)을 통해 수행한다.

weakref.finalize(객체, 함수)함수는 객체가 가비지 콜렉트 될 때 입력받은 함수를 호출시킨다. 반환되는 객체는 .alive를 통해 해당 객체가 제거되기 전인지를 확인할 수 있다.


참조는 강한 참조와 약한 참조로 나뉜다. 지금까지 얘기한 참조는 강한 참조에 속하여 refcount를 1씩 늘리는 참조이다. 약한 참조는 refcount를 늘리지 않으면서 참조를 하고 싶을 때 사용한다. weakref.finalize함수에서도 약한 참조가 쓰였다. weakref.ref(객체)는 콜러블 객체로, 호출 시 해당 객체를 참조할 수 있지만 이 방식으로는 refcount를 늘리지 않는 약한 참조의 형태로 참조가 된다. 만약 해당 객체가 제거되었다면 호출 시 None이 반환된다.

weakref에는 다양한 컬렉션이 준비되어있다.

WeakValueDictionary는 dict와 비슷하지만 value가 약한 참조라서 해당 객체가 사라지면 항목도 key와 함께 사라진다. 주로 캐시를 구현할 때 사용한다. 캐시할 항목을 모두 WeakValueDictionary에 넣어놓고 다른 작업을 하다가 특정 객체가 필요할 경우 WeakValueDictionary를 먼저 확인해보고 없으면 새로 생성하는 방식이다.

WeakKeyDictionary는 반대로 key가 약한 참조로 된 dict이다. 객체에 직접적으로 속성을 추가하지 않고 추가적인 데이터를 연결하는 데에 쓰인다. 디스크립터에 유용하다.

WeakSet은 각 요소에 약한 참조로 된 set이다. 자신이 가지고 있는 모든 객체를 담는 컨테이너를 만들어야 할 때 WeakSet을 이용하지 않으면 자신이 가지고 있던 객체가 제거되더라도 가비지 콜렉트 되지 않을 것이다.

사용자 정의 클래스, set는 약한 참조의 대상이 될 수 있다. list, dict는 약한 참조의 대상이 될 수 없지만 사용자 정의 클래스를 만들고 list나 dict를 상속하면 약한 참조를 할 수 있다. int나 tuple은 약한 참조를 할 수 없다. CPython의 구현방식과 최적화에 의해 발생하는 현상이다. 추가로 어떤 자료형이 가능한지 살펴보려면 여기 참고


tuple은 절대 얕은 복사가 되지 않는다. tuple 생성자, [:], copy.copy를 사용해도 안된다. tuple은 불변형이기 때문에 같은 객체들로 구성된 항목을 가질 경우 항상 ==의 결과도 참일 것이기 때문에 얕은 복사를 할 필요도 없다. str, bytes, frozenset도 불변형이므로 얕은 복사가 되지 않는다.

많이 쓰이는 문자열이나 정수를 공유하여 최적화를 하기도 하는데 이를 인터닝(interning)이라 한다. 모든 문자열이나 정수를 인터닝 하지는 않고, 인터닝의 기준은 문서화되어있지 않지만 일반적으로 정수는 -5~256이 인터닝되어있다. sys.intern함수를 통해 기본적으로 인터닝 되지 않는 객체를 인터닝할 수 있다.

a = 256
b = 256
print(a is b)	#True
a = 257
b = 257
print(a is b)	#False

컴파일 시간의 상수 중복 제거

jupyter notebook이나 터미널에서 실행하면 인터프리터에 의해 라운드 별로 묶여 실행된다. 이 때 global scope에서는 각 줄마다 하나의 라운드로 인식되어 한 줄씩 실행하는 반면 제어문으로 묶여 들여쓰기 된 블럭이 있으면 통채로 하나의 라운드가 되기도 한다. 하나의 라운드 안에 같은 상수가 여러 개 나오면 중복되는 객체를 없애는 최적화가 일어나기도 한다. 그래서 아래와 같은 결과가 나온다.

if True:
  a = 257
  b = 257
  print(a is b)		#True

만약 .py 파일에서 실행한다면 인터프리터 전에 컴파일러가 실행되는데, 이때 라운드가 코드 전체로 인식되어 전체 코드에서의 상수 중복이 없어진다. 여기 참고


파이썬 모든 객체는 정체성, 자료형, 값을 가지고 있고 실행 중에는 값만 바뀐다. (__class__를 바꿈으로써 자료형을 바꿀 수 있긴 하지만 절대 하면 안된다)
+=, *=같은 복합할당 연산자는 왼쪽 변수가 불변 객체라면 객체를 새로 생성하고 가변 객체라면 기존 객체를 변경한다.
파일을 열 땐 with문으로 여는 게 좋다. 중간에 예외가 발생하더라도 파일을 반드시 닫기 때문이다.

profile
안녕하세요

0개의 댓글