해당 글은 유튜버 ArajanCodes의 영상을 정리한 글임을 밝힙니다.
import random
import string
from dataclasses import dataclass
def generate_id() -> str:
return "".join(random.choices(string.ascii_uppercase, k=12))
@dataclass
class Person:
name: str
address: str
def main() -> None:
person = Person(name="John", address="123 Main st")
print(person)
if __name__=="__main__":
main()
지난 글의 파이썬 dataclasses
모듈을 사용해서 데이터-지향 클래스를 구현하는 방법에 이어 파이썬 데이터 클래스에서 제공하는 편의기능들에 대해서 알아보자.
위의 예시와 같이 정의하면 Person
클래스를 인스턴스화 할 때마다 이름과 주소 에 해당하는 값을 넣어주어야만 한다.
만약에 구현하고자 하는 시스템에서 사람 인스턴스가 활성화되었는지 active
변수를 두고 지속적으로 트래킹을 해야한다고 해보자. 이 경우 인스턴스가 만들어지는 순간에는 active
값이 항상 True
일 것이므로 매번 참 값을 넣어주면서 생성하는 것보다는 기본 값을 사용하는 것이 좋다.
dataclass
에서 선언한 인스턴스 변수의 기본 값을 할당하는 방법에 대해서 알아보자.
@dataclass
class Person:
name: str
address: str
active: bool = True
이렇게 하면 따로 active
필드 값을 넣어주지 않아도 기본적으로 참 값을 가지고 있는 것을 확인할 수 있다.
원시타입의 경우는 이렇게 간단하게 가능한데, 더 복잡한 자료형일 경우는?
이번엔 사람 클래스가 인자로 이메일 주소를 받는다고 해보자. 이메일 주소는 여러 개 소유할 수 있으므로 이메일 주소의 타입은 list[str]
의 형태로 선언하는 것이 타당해보인다.
다음과 같이 선언한 뒤에 실행해보자.
@dataclass
class Person:
name: str
address: str
active: bool = True
email_addresses: list[str] = []
에러가 발생했다. 그리고 친절하게 default_factory
를 사용하라는 친절한 안내까지.
위와 같이 실행하면 파이썬 인터프리터가 email_addresses: list[str] = []
구문에서
[]
객체를 만나는 순간 하나의 배열만을 만들어놓고 여러 개의 인스턴스가 생성되어도 해당 객체를 가리키도록 하려고 할 것이기 때문이다.
자료형이 원시타입이 아닌 경우에는 에러 메세지가 알려주었다시피 default_factory
를 사용해야 한다.
먼저 dataclasses
패키지에서 field
함수를 새롭게 임포트해야 한다.
from dataclasses import dataclass, field
그 다음, 위에서 선언한 이메일 주소 필드를 다음과 같이 수정,
...
@dataclass
class Person:
name: str
address: str
active: bool = True
email_addresses: list[str] = field(default_factory=list)
...
이렇게 하면 인스턴스가 생성될 때마다 디폴트 팩토리를 통해서 실제로 새로운 객체를 만든다.
디폴트 팩토리에 대입한 함수는 인스턴스화할 때마다 실행되기 때문에 본인이 정의한 함수를 넣을 수도 있다.
...
@dataclass
class Person:
name: str
address: str
active: bool = True
email_addresses: list[str] = field(default_factory=list)
id: str = field(default_factory=generate_id)
...
generate_id
함수와 디폴트 팩토리를 넣어서 사람 인스턴스가 생성되면 12자리의 영어 대문자 아이디를 가지도록 해보았다.
12자리의 랜덤 아이디가 생성된 것을 볼 수 있다.
디폴트 팩토리를 사용했다고 하더라도 Person 생성자에 id
값을 임의로 넣어주면 디폴트 팩토리는 호출되지 않고 인자로 전달한 값으로 초기화되게 된다.
def main() -> None:
person = Person(name="John", address="123 Main st", id="arjan")
그런데 id
값을 명시적으로 초기화하는 행위를 제한하고 싶다면?
데이터 클래스에서는 자동으로 생성되는 생성자 __init__
의 멤버에서 원하는 필드값들을 제외하는 기능을 제공하고 있다.
field(init=False, ...)
와 같이 설정하면 된다.
...
@dataclass
class Person:
name: str
address: str
active: bool = True
email_addresses: list[str] = field(default_factory=list)
id: str = field(init=False, default_factory=generate_id)
...
init=False
로 하였기 때문에 id
값은 더 이상 생성자를 통해서 초기화할 수 있는 값이 아니다.
이제 사람을 검색할 수 있는 어떤 문자열을 인스턴스 변수로 가지고 있으면 좋겠다.
이름
과 주소
를 이어붙인 문자열을 담을 수 있는 search_string
이라는 필드값을 추가해야 한다고 가정해보자.
이 값은 생성자 호출이 완료되고 다른 속성들이 모두 값을 가지고 난 뒤에 생성이 되어야 한다.
이럴 때 사용할 수 있는 기능이 __post_init__
이다.
@dataclass
class Person:
...
search_string: str = field(init=False)
def __post_init__(self) -> None:
self.search_string = f"{self.name} {self.address}"
__init__
호출이 완료되어서 Person
인스턴스가 name
값과 address
값을 가지고 난 후에,
__post_init__
가 이어서 자동적으로 실행되며,
기존의 필드 값들을 사용해서 search_string
이라는 새로운 필드 값을 생성한다.
개발을 하다보면 private 한 벼수와 protected 와 같은 접근 제어자를 사용해야할 일이 생긴다.
파이썬에서는 protected의 경우 앞에 _
(single underscore) ,
private의 경우 앞에 __
(double underscore) 를 붙여서 클래스 내부에서만 사용되는 값이라고 명시하는 암묵적인 규칙이 있다.
이번엔 id
를 protected 로, search_string
은 private 변수로 바꿔보았다.
사람 인스턴스를 출력했을 때 protected 변수와 private 변수를 모두 출력해버리는 것을 볼 수 있다.
클래스 내부에서만 사용할 값인데, 외부에서 이런 값을 다 볼 수 있게 출력하는 것은 문제가 있다.
repr=False
를 사용하면 원치 않는 필드 값이 출력되는 것을 방지할 수 있다.
데이터의 불변성을 보장해야 한다면 frozen
옵션을 사용할 수 있다.
@dataclass(frozen=True)
위와 같이 frozen
을 True
로 설정하게 되면 인스턴스 타입이 FrozenInstance
로 바뀌게 된다.
FrozenInstance
는 더 이상 인스턴스 변수의 값을 바꿀 수 없으며 바꾸려고 한다면
dataclasses.FrozenInstanceError
에러를 만나게 된다.
인스턴스가 frozen 되었으므로 __post_init__
메소드도 사용할 수 없다.
불변 객체를 만드는 것은 코딩 중에 신경써야할 요소를 줄이게 되어
결론적으로는 프로그래밍을 쉽게 하고, 프로그램을 안정적으로 만들어주므로 사용할 수 있다면 사용하는 것이 좋다.
물론 파이썬에서는 const
나 final
과 같은 상수 개념은 없기 때문에
person = Person(name="John", address="123 Main st")
person = Person(name="Kevin", address="125 Main st")
위와 같이 person
이 다른 객체를 가리키도록 바꾸는 것은 막지 못한다.
kw_only
를 설정하면 키워드 인자만을 받도록 강제할 수 있다.
kw_only=True
로 설정하고, main
함수에서 사용했던 키워드를 모두 제거해보면 에러가 발생한다.
@dataclass(kw_only=False)
class Person:
name: str
...
def main() -> None:
person = Person("John", "123 Main St")
print(person)
파이썬 3.10에서 구조적 패턴 매칭 (structural pattern matching) 이 처음 도입되었는데,
데이터 클래스도 이를 지원하도록 업데이트 되었다.
@dataclass(match_args=True)
Person
타입 객체의 이름 속성을 알고자 한다면 __dict__
를 사용해도 접근할 수 있다.
def main() -> None:
person = Person(name="John", address="123 Main st")
print(person.__dict__["name"])
print(person)
데이터 클래스에서는 이러한 인스턴스 속성들이 __dict__
라는 이름의 딕셔너리 형태로 저장되기 때문에 그렇다.
딕셔너리 객체에 접근하는 것은 일반적인 경우라면 문제가 되지 않지만,
많은 수의 접근이 이루어지는 객체라면 더 빠르고 효율적인 __slots__
를 사용하는 더 좋다.
@dataclass(slots=True)
class Person:
name: str
...
ArjanCodes의 원본 영상에서는 테스트 결과 딕셔너리를 사용했을 때보다 약 21% 의 성능 향상이 있다고 했다.
그럼 모든 경우에 slots
를 사용하면 될까?
성능 향상이 있으면 그에 대한 트레이드오프가 있기 마련인데,
slots
의 경우에는 다중 상속을 사용할 수 없다는 문제가 있다.
@dataclass(slots=True)
class PersonSlots:
name: str
address: str
email: str
@dataclass(slots=True)
class EmployeeSlots:
dept: str
class PersonEmployee(PersonSlots, EmployeeSlots):
pass
부모 클래스가 모두 slots 를 사용하면
자식 클래스는 어느 부모의 slots 를 상속해야할지 모르기 때문에 에러가 발생한다.
이런 단점과는 별개로,
애초에 mixin 이나 다중 상속 과 같은 기능은 사용하지 않는 것을 추천한다고 한다.