다른 언어를 사용하다 파이썬을 접한 프로그래머들은 클래스에 getter 와 setter 메서드를 명시적으로 정의하곤 한다.
class OldResistor:
def __init__(self, ohms):
self._ohms = ohms
def get_ohms(self):
return self._ohms
def set_ohms(self, ohms):
self._ohms = ohms
setter
와 getter
를 사용하기는 쉽지만, 이런 코드는 파이썬답지 않다.
r0 = OldResistor(50e3)
print('이전', r0.get_ohms())
r0.set_homs(10e3)
print('이후', r0.get_ohms())
>>>
이전: 50000.0
이후: 10000.0
특히 필드 값을 증가시키는 연산 등의 경우에 이런 메서드를 사용하면 코드가 지저분해진다.
r0.set_ohms(r0.get_homs() - 4e3)
하지만 이런 유틸리티 메서드를 사용하면 클래스 인터페이스를 설계할 때 도움이 되기도 한다. 즉 setter
와 getter
같은 유틸리티 메서드를 쓰면 기능을 캡슐화하고, 필드 사용을 검증하고, 경계르 설정하기 쉬워진다. 클래스가 시간이 지남에 따라 진화하기 때문에 클래스를 설계할 때는 클래스를 호출하는 쪽에 영향을 미치지 않음을 보장하는 것이 중요하다.
하지만 파이썬에서는 명시적인 세터나 게터 메서드를 구현할 필요가 없다. 대신 다음과 같이 항상 단순한 공개 애트리뷰트로부터 구현을 시작할 수 있다.
class Resistor:
def __init__(self, ohms):
self.ohms = ohms
self.voltage = 0
self.current = 0
r1 = Resistor(50e3)
r1.ohms = 10e3
이렇게 애트리뷰트를 사용하면 필드를 제자리에서 증가시키는 등의 연산이 더 자연스럽고 명확해진다.
r1.ohms += 5e3
나중에 애트리뷰트가 설정될 때 특별한 기능을 수행해야 한다면, 애트리뷰트를 @property
데코레이터와 대응하는 setter
애트리뷰트로 옮겨갈 수 있다.
다음은 Resistor
의 새 하위 클래스를 만든다. Registor
에서 voltage
프로퍼티에 값을 대입하면 current
값이 바뀐다. 코드가 제대로 작동하려면 setter
와 getter
의 이름이 우리가 의도한 프로퍼티 이름과 일치해야 한다.
class VoltageResistance(Resistor):
def __init__(self, ohms):
super().__init__(ohms)
self._voltage = 0
@property
def voltage(self):
return self._voltage
@voltage.setter
def voltage(self, voltage):
self._voltage = voltage
self.current = self._voltage / self.ohms
이제 voltage
프로퍼티에 대입하면 voltage
세터 메서드가 호출되고, 이 메서드는 객체의 current
애트리뷰트를 변경도니 전압 값에 맞춰 갱신한다.
r2 = VoltageResistance(1e3)
print(f'이전: {r2.current:.2f} 암페어')
r2.voltage = 10
print(f'이후: {r2.current:.2f} 암페어')
>>>
이전: 0.00 암페어
이후: 0.01 암페어
프로퍼티에 대해 setter
를 지정하면 타입을 검사하거나 클래스 프로퍼티에 전달된 값에 대한 검증을 수행할 수 있다.
class BoundedResistance(Resistor):
def __init__(self, ohms):
super().__init__(ohms)
@property
def ohms(self):
return self._ohms
@ohms.setter
def ohms(self, homs):
if ohms < 0:
raise ValueError(f'저항 > 0 이어야 합니다. 실제 값 {ohms}')
self._ohms = ohms
r3 = BoundedResistance(1e3)
r3.ohms = 0
BoundedResistance(-5)
>>>
Traceback ...
ValueError: 저항 > 0 이어야 합니다. 실제 값 0
Traceback ...
ValueError: 저항 > 0 이어야 합니다. 실제 값 -5
위 처럼 잘못된 저항값 대입, 생성자에 잘못된 값을 넘기는 경우 에러가 발생한다.
예외가 발생하는 이유는 BoundedResistance.__init__
이 Resistor.__init__
을 호출하고 이 초기화 메서드는 다시 self.ohms = -5
라는 대입문을 실행하기 때문이다. 이 대입으로 인해 BoundedResistance
에 있는 @ohms.setter
메서드가 호출되고, 이 setter
메서드는 객체 생성이 끝나기 전에 즉시 저항을 검증하는 코드를 실행한다.
심지어 @property
를 사용해 부모 클래스에 정의된 애트리뷰트를 불변으로 만들 수도 있다.
class FixedResistance(Resistor):
def __init__(self, ohms):
super().__init__(ohms)
@property
def ohms(self):
return self._ohms
@ohms.setter
def ohms(self, ohms):
if hasattr(self, '_ohms'):
raise AttributeError("Omhs는 불변 객체입니다.")
self._ohms = ohms
이 객체를 만든 다음 프로퍼티에 값을 대입하면 예외가 발생한다.
r4 = FixedResistnace(1e3)
r4.ohms = 2e3
>>>
Traceback ...
AttributeError: Ohms는 불변 객체입니다.
@property
메서드를 사용해 setter
와 getter
를 구현할 때는 예기치 않은 동작을 수행하지 않도록 만들어야 한다. 예를 들어 getter
프로퍼티 메서드 안에서 다른 애트리뷰트를 정의하면 안된다.
class MysteriousResistor(Resistor):
@property
def ohms(self):
self.voltage = self._ohms * self.current
return self._ohms
@ohms.setter
def ohms(self, ohms):
self._ohms = ohms
r7 = MysteriousResistor(10)
r7.current = 0.01
print(f'이전: {r7.voltage:.2f}')
r7.ohms
print(f'이후: {r7.voltage:.2f}')
>>>
이전: 0.00
이후: 0.10
getter
나 setter
를 정의할 때 가장 좋은 정책은 관련이 있는 객체 상태를 @property.setter
메서드 안에서만 변경하는 것이다. 동적으로 모듈을 임포트하거나, 아주 시간이 오래 걸리는 도우미 함수를 호출하거나, I/O를 수행하거나, 비용이 많이 드는 데이터베이스 질의를 수행하는 등 호출하는 쪽에서 예상할 수 없는 부작용을 만들어내면 안된다. 더 복잡하거나 느린 연산의 경우에는 일반적인 메서드를 사용하라.
@property
의 가장 큰 단점은 애트리뷰트를 처리하는 메서드가 하위 클래스 사이에서만 공유될 수 있다는 것이다. 서로 관련이 없는 클래스 사이에 같은 구현을 공유할 수는 없다. 하지만 파이썬은 재사용 가능한 프로퍼티 로직을 구현할 때는 물론 다른 여러 용도에도 사용할 수 있는 descriptor 기능을 제공한다.
기억해야 할 내용
- 새로운 클래스 인터페이스를 정의할 때는 간단한 공개 애트리뷰트에서 시작하고, 세터나 게터 메서드를 가급적 사용하지 말라.
- 객체에 있는 애트리뷰트에 접근할 때 특별한 동작이 필요하면
@property
로 이를 구현할 수 있다.@property
메서드를 만들 때는 최소 놀람의 법칙을 따르고 이상한 부작용을 만들어내지 말라.@property
메서드가 바르게 실행되도록 유지하라. 느리거나 복잡한 작업의 경우에는 프로퍼티 대신 일반적인 메서드를 사용하라.