Home [Better Way #46] 재사용 가능한 @property 메서드를 만들려면 디스크립터를 사용하라
Post
Cancel

[Better Way #46] 재사용 가능한 @property 메서드를 만들려면 디스크립터를 사용하라

본문은 “파이썬 코딩의 기술 (Effective Python, 2판)”“Chapter 06. Metaclasses and Attributes”을 읽고 정리한 내용입니다.


TL;DR

  1. @property 메서드의 동작과 검증 기능을 재사용하고 싶다면 디스크립터 클래스를 만들어라.
  2. 디스크립터 클래스를 만들 때는 메모리 누수를 방지하며 각 인스턴스에 대해 따로 값을 추적할 수 있도록 하기 위해 WeakKeyDictionary를 사용하라.
  3. __getattribute__디스크립터 프로토콜을 사용해 애트리뷰트 값에 접근하는 방식을 정확히 이해하라.




@property 내장 기능의 가장 큰 문제점은 재사용성이다.

  1. @property가 데코레이션하는 메서드를 같은 클래스에 속하는 여러 애트리뷰트로 사용할 수 없다.
  2. 서로 무관한 클래스 사이에서 @property 데코레이터를 적용한 메서드를 재사용할 수도 없다.

따라서 동일한 기능을 다시 사용하고 싶다면 같은 @property 메서드대응되는 setter 메서드를 번거롭게 다시 작성해야 한다.

이처럼 @property 메서드를 재사용하려는 경우에는 디스크립터 프로토콜을 적용할 수 있다.


디스크립터 프로토콜

  • 애트리뷰트 접근을 해석하는 방법을 정의한다.
  • 디스크립터 클래스는 __get____set__ 메서드를 제공하며, 이 두 메서드를 통해 다른 코드 없이도 @property 메서드와 그에 대응되는 setter 메서드로 구현하려 했던 동작을 재사용 가능한 형태로 구현할 수 있다.
  • 같은 로직을 한 클래스 안에 속한 여러 다른 애트리뷰트에 적용할 수 있으므로, 디스크립터가 믹스인보다 낫다.


디스크립터 애트리뷰트에 대한 접근을 파이썬이 처리하는 방법 (__getattribute__)

  • 다음과 같이, Grade의 인스턴스를 클래스 애트리뷰트로 가지는 Exam 클래스를 살펴보자.

    1
    2
    3
    4
    5
    6
    
    class Grade:
        def __get__(self, instance, instance_type):
            ...
          
        def __set__(self, instance, value):
            ...
    
    1
    2
    3
    4
    5
    
    class Exam:
        # 클래스 애트리뷰트
        math_grade = Grade()
        writing_grade = Grade()
        sciend_grade = Grade()
    
  • 프로퍼티 대입은 다음과 같이 __set__ 메서드로 해석된다.

    1
    2
    3
    4
    
    exam = Exam()
    exam.writing_grade = 40
      
    # Exam.__dict__['writing_grade'].__set__(exam, 40)
    
  • 프로퍼티 접근은 다음과 같이 __get__ 메서드로 해석된다.

    1
    2
    3
    
    exam.writing_grade
      
    # Exam.__dict__['writing_grade'].__get__(exam, Exam)
    
  • 이는 object__getattribute__ 메서드의 동작이다.

    1. Exam 인스턴스writing_grade 라는 이름의 애트리뷰트가 있는지 확인하고, 있으면 그것을 사용한다.
    2. Exam 인스턴스에 해당 애트리뷰트가 없으면, Exam 클래스의 애트리뷰트를 대신 사용한다.
    3. 클래스 애트리뷰트__get____set__ 메서드가 정의된 객체라면, 디스크립터 프로토콜을 따른다.


Wrong Way #1

다음과 같은 코드에서는 Exam 클래스가 처음 정의될 때, 각 클래스 애트리뷰트에 대한 Grade 인스턴스가 단 한 번만 생성된다.

즉, Exam 인스턴스가 생성될 때마다 매번 새로운 Grade 인스턴스가 생성되는 것이 아닌, Exam 클래스가 정의될 때 한 번 생성된 인스턴스를 계속해서 재사용하게 되는 것이다.

1
2
3
4
5
6
7
8
9
10
11
class Grade:
    def __init__(self):
        self._value = 0
        
    def __get__(self, instance, instance_type):
        return self._value
    
    def __set__(self, instance, value):
        if not (0 <= value <= 100):
            raise ValueError("점수는 0과 100 사이입니다.")
        self._value = value
1
2
3
4
5
class Exam:
    # 클래스 애트리뷰트 (Exam 클래스 정의 시 단 한 번만 생성)
    math_grade = Grade()
    writing_grade = Grade()
    science_grade = Grade()

따라서 다음과 같이 한 Exam 인스턴스의 애트리뷰트만 새로운 값으로 할당하더라도, 전체 인스턴스의 애트리뷰트 값이 모두 같은 값으로 할당되게 되는 문제가 발생한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
first_exam = Exam()
first_exam.writing_grade = 82
first_exam.science_grade = 99
print(f"쓰기: {first_exam.writing_grade}")
print(f"과학: {first_exam.science_grade}")
# 쓰기: 82
# 과학: 99

second_exam = Exam()
second_exam.writing_grade = 75
print(f"두 번째 쓰기 점수: {second_exam.writing_grade} -> 맞음")
print(f"첫 번째 쓰기 점수: {first_exam.writing_grade} -> 틀림")
# 두 번째 쓰기 점수: 75 -> 맞음
# 첫 번째 쓰기 점수: 75 -> 틀림


Wrong Way #2

위의 문제점을 해결하기 위해, 다음과 같이 Grade 클래스가 Exam 인스턴스에 대해 따로 값을 추적하도록 딕셔너리를 이용하여 구현할 수 있다.

1
2
3
4
5
6
7
8
9
10
11
12
13
class Grade:
    def __init__(self):
        self._values = {}    # 각 Exam 인스턴스에 대해 따로 값을 추적하기 위한 딕셔너리
        
    def __get__(self, instance, instance_type):
        if instance is None:
            return self
        return self._values.get(instance, 0)
    
    def __set__(self, instance, value):
        if not (0 <= value <= 100):
            raise ValueError("점수는 0과 100 사이입니다.")
        self._values[instance] = value

하지만 이러한 경우에는 프로그램이 실행되는 동안 __set__ 호출에 전달된 모든 Exam 인스턴스에 대한 참조를 저장하기 때문에 인스턴스에 대한 reference counter가 절대로 0이 될 수 없고, 그로 인해 garbage collector가 인스턴스 메모리를 결코 재활용할 수 없다.

즉, 메모리 누수(leak)가 발생한다는 문제가 발생한다.


Correct Way: WeakKeyDictionary

위의 문제점을 해결하기 위해서 파이썬 weakref 내장 모듈에서 제공하는 WeakKeyDictionary를 사용할 수 있다.

WeakKeyDictionary는 딕셔너리에 객체를 저장할 때 일반적인 strong reference 대신 weak reference를 사용한다. 그리고 파이썬의 garbage collectorweak reference로만 참조되는 객체가 사용 중인 메모리를 언제든지 재활용할 수 있다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
from weakref import WeakKeyDictionary

class Grade:
    def __init__(self):
        self._values = WeakKeyDictionary()    # WeakKeyDictionary로 변경!
        
    def __get__(self, instance, instance_type):
        if instance is None:
            return self
        return self._values.get(instance, 0)
    
    def __set__(self, instance, value):
        if not (0 <= value <= 100):
            raise ValueError("점수는 0과 100 사이입니다.")
        self._values[instance] = value

따라서 WeakKeyDictionary에 저장된 인스턴스가 더 이상 쓰이지 않는다면, 즉 해당 객체를 가리키는 모든 strong reference가 사라졌다면, garbage collector가 해당 메모리를 재활용할 수 있어 메모리 누수가 발생하지 않는 방법으로 각 인스턴스에 대해 따로 값을 추적할 수 있게 된다.

1
2
3
4
5
6
7
8
9
first_exam = Exam()
first_exam.writing_grade = 82
second_exam = Exam()
second_exam.writing_grade = 75
print(f"두 번째 쓰기 점수: {second_exam.writing_grade} -> 맞음")
print(f"첫 번째 쓰기 점수: {first_exam.writing_grade} -> 맞음")

# 두 번째 쓰기 점수: 75 -> 맞음
# 첫 번째 쓰기 점수: 82 -> 맞음
This post is licensed under CC BY 4.0 by the author.

[Python] heapq 우선순위 큐에서 최솟값 추출하기

[Better Way #47] 지연 계산 애트리뷰트가 필요하면 __getattr__, __getattribute__, __setattr__을 사용하라