Python 데이터 클래스 (dataclasses) - 02

HnBrd·2023년 7월 24일
0

파이썬

목록 보기
2/3
post-thumbnail

해당 글은 유튜버 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 필드 값을 넣어주지 않아도 기본적으로 참 값을 가지고 있는 것을 확인할 수 있다.

원시타입의 경우는 이렇게 간단하게 가능한데, 더 복잡한 자료형일 경우는?

default factory

이번엔 사람 클래스가 인자로 이메일 주소를 받는다고 해보자. 이메일 주소는 여러 개 소유할 수 있으므로 이메일 주소의 타입은 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 값은 더 이상 생성자를 통해서 초기화할 수 있는 값이 아니다.

__post_init__

이제 사람을 검색할 수 있는 어떤 문자열을 인스턴스 변수로 가지고 있으면 좋겠다.
이름주소 를 이어붙인 문자열을 담을 수 있는 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 이라는 새로운 필드 값을 생성한다.

__repr__ 제한

개발을 하다보면 private 한 벼수와 protected 와 같은 접근 제어자를 사용해야할 일이 생긴다.

파이썬에서는 protected의 경우 앞에 _ (single underscore) ,
private의 경우 앞에 __ (double underscore) 를 붙여서 클래스 내부에서만 사용되는 값이라고 명시하는 암묵적인 규칙이 있다.

이번엔 id 를 protected 로, search_string 은 private 변수로 바꿔보았다.

사람 인스턴스를 출력했을 때 protected 변수와 private 변수를 모두 출력해버리는 것을 볼 수 있다.
클래스 내부에서만 사용할 값인데, 외부에서 이런 값을 다 볼 수 있게 출력하는 것은 문제가 있다.

repr=False 를 사용하면 원치 않는 필드 값이 출력되는 것을 방지할 수 있다.


frozen

데이터의 불변성을 보장해야 한다면 frozen 옵션을 사용할 수 있다.

@dataclass(frozen=True)

위와 같이 frozenTrue 로 설정하게 되면 인스턴스 타입이 FrozenInstance 로 바뀌게 된다.
FrozenInstance 는 더 이상 인스턴스 변수의 값을 바꿀 수 없으며 바꾸려고 한다면
dataclasses.FrozenInstanceError 에러를 만나게 된다.

'

인스턴스가 frozen 되었으므로 __post_init__ 메소드도 사용할 수 없다.

불변 객체를 만드는 것은 코딩 중에 신경써야할 요소를 줄이게 되어
결론적으로는 프로그래밍을 쉽게 하고, 프로그램을 안정적으로 만들어주므로 사용할 수 있다면 사용하는 것이 좋다.

물론 파이썬에서는 constfinal 과 같은 상수 개념은 없기 때문에

person = Person(name="John", address="123 Main st")
person = Person(name="Kevin", address="125 Main st")

위와 같이 person 이 다른 객체를 가리키도록 바꾸는 것은 막지 못한다.

Python 3.10 에서 추가된 기능

kw_only

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)

match_args

파이썬 3.10에서 구조적 패턴 매칭 (structural pattern matching) 이 처음 도입되었는데,
데이터 클래스도 이를 지원하도록 업데이트 되었다.

@dataclass(match_args=True)

slots 옵션

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 이나 다중 상속 과 같은 기능은 사용하지 않는 것을 추천한다고 한다.

profile
잡식

0개의 댓글

관련 채용 정보