단단한 파이썬 9. 데이터 클래스

    들어가기 전

    이 글은 단단한 파이썬 9장 데이터 클래스를 공부하며 작성한 글입니다.

     


    1. 데이터 클래스

    데이터 클래스의 공식 문서는 이곳이다.

     


    2. 데이터 클래스의 필요성

    데이터 클래스는 자바 17부터 등장한 Record와 비슷한 역할을 한다. 클래스로 사용하기에는 좀 애매하고, 데이터끼리 묶어서 의미를 부여하고 싶을 때 사용한다. 그렇다면 파이썬 관점에서 필요한 이유를 다시 한번 살펴보자.

    # 분할된 데이터
    name = 'tom'
    age = 2
    
    # 하나로 묶은 데이터
    person = ('tom', 2)

    코드를 작성하다보면 여러 개의 데이터들을 하나의 데이터로 묶는 경우가 필요할 때가 있다. 위의 예시에서 name, age를 각각의 변수에 선언하는 것보다 person이라는 변수 하나에 묶는 경우가 가독성이 더 좋을 것이다. 

    # 하나로 묶은 데이터
    person = ('tom', 2)
    
    
    # 가능한가? 그리고 뭘 뜻함? 
    person[2]

    여러 데이터를 하나로 묶는데는 튜플이나 딕셔너리를 사용할 수 있다. 그러나 튜플이나 딕셔너리에는 다음과 같은 단점이 있다. 

    • 튜플에서는 어떤 인덱스가 무엇을 의미하는지 알 수 없음. 어떤 인덱스까지 접근가능한지도 모름 
    • 딕셔너리는 어떤 Key값이 존재하는지 모름. 그리고 각 Key에 어떤 타입이 있는지 알 수 없음. (타입 체커를 이용할 수 없음) 

    기본 내장형 타입을 이용하는 경우 이런 문제점을 극복할 수 없다. 이 문제점을 극복하는데 dataclass를 사용할 수 있다.


    3. 데이터 클래스 선언 방법

    from dataclasses import dataclass
    
    @dataclass
    class InventoryItem:
        name: str
        unit_price: float
        quantity_on_hand: int = 0

    데이터 클래스를 사용하려면 다음과 같이 하면 된다.

    • @dataclass 데코레이터를 붙여야 함. 
    • 필드와 타입 힌트, 혹은 기본값을 설정해야 함. 

    위와 같이 선언하면 데이터 클래스를 하나 선언하게 되는 것이다. 

    # None을 만들어 주지는 않고, 필요한 값을 반드시 넣도록 강제한다.
    A = InventoryItem('a', 0)
    B = InventroyItem()
    >>>
        B = InventoryItem()
    TypeError: __init__() missing 2 required positional arguments: 'name' and 'unit_price'
    • 생성자를 통해 생성하면 됨.
    • 생성 시, 기본값이 주어지지 않은 필드는 반드시 값을 입력하도록 한다. 그렇지 않으면 에러가 발생함. 

    데이터 클래스는 생성자에 필요한 값을 미리 셋팅하지 않으면 에러를 발생하도록 하면서 '빠른 실패의 원칙'을 만족시킨다. 

     


    4. 데이터 클래스의 필드 자동 완성

    person = {'name': 'Tom', 'age': 10}
    # 무슨 타입? age가 존재하긴 하나? 
    person.get('age')

    딕셔너리는 Key - Value에 효율적으로 접근할 수 있도록 도와주지만 다음 단점을 가진다.

    • 각 Key에 대한 Value의 타입을 알 수 없음. 타입체커의 도움을 받을 수 없음.
    • 딕셔너리에 어떤 Key가 존재하는지 알 수 없음. 

    데이터클래스는 딕셔너리의 이런 문제점을 해결해 준다.

    from dataclasses import dataclass
    
    
    @dataclass
    class Person:
        name: str
        age: int
    
    p = Person('a', '10')
    name = p.name
    age = p.age
    • Person 데이터 클래스내에 어떤 필드가 존재하는지 클래스 선언부를 보면 알 수 있음. IDE를 통해 자동완성까지 지원. 
    • 각 필드 타입에 대한 타입체킹 기능까지 지원함. 
    $ mypy 9-2.py
    >>> 
    9-2.py:14: error: Argument 2 to "Person" has incompatible type "str"; expected "int"  [arg-type]
    Found 1 error in 1 file (checked 1 source file)

    타입체커로 위 코드를 살펴보면, 생성자에 전달된 값의 타입이 문제가 있음을 타입체커가 발견하는 것을 알 수 있다. 

     


    5. 데이터 클래스의 사용법

    데이터 클래스는 별 생각 없이 사용해도 좋으나, 알고 사용하면 더 좋을만한 기능들이 있다. 아래에서 하나씩 살펴보고자 한다. 


    문자열 변환 → __str__, __repr__ 자동구현.

    from dataclasses import dataclass, field
    
    class InnerClass:
        pass
    
    @dataclass
    class MyData:
        name: str = 'a'
        my_class: InnerClass = field(default_factory=InnerClass)

    @dataclass 어노테이션을 붙이면 __repr__, __str__ 메서드가 자동으로 생성된다. print(객체)를 했을 때, 객체의 필드 내용을 볼 수 있게 되는 것이다.

    a = MyData()
    print(a)
    print(str(a))
    print(repr(a))
    
    >>>
    MyData(name='a', my_class=<__main__.InnerClass object at 0x00000172F75B4160>)
    MyData(name='a', my_class=<__main__.InnerClass object at 0x00000172F75B4160>)
    MyData(name='a', my_class=<__main__.InnerClass object at 0x00000172F75B4160>)

    위 클래스를 생성하고 위에서처럼 print() 함수를 이용해 호출해보면 다음 결과를 확인할 수 있다.

     


    동일성 비교 → __eq__() 메서드 자동 구현

    from dataclasses import dataclass
    
    @dataclass(eq=True)
    class MyData:
        name: str
    
    @dataclass(eq=False)
    class MyDataNotEq:
        name: str
    
    print(MyData('a') == MyData('a'))
    print(MyDataNotEq('a') == MyDataNotEq('a'))
    >>>
    True
    False
    • @dataclass(eq=True)는 Default이다. 따라서 아무런 값도 주지 않으면 생성된 데이터 클래스는 자동으로 __eq__ 메서드가 구현되어있다. 
    • 자동으로 구현된 __eq__ 메서드는 각 데이터 클래스의 필드가 같은 값을 가지는지 비교한다. 

    대소비교 <, >, <=, >=

    • 데이터 클래스는 기본적으로 비교 연산을 지원하지 않는다. (<, >, <=, >=). 비교연산이 가능해야 정렬이 가능하기 때문에 정렬 역시 지원하지 않는다.
    • 만약 비교연산과 정렬을 지원하고 싶다면 아래처럼 eq=True, order=True로 설정해야한다. 
    from dataclasses import dataclass
    
    @dataclass(eq=True, order=True)
    class BigNumber:
        num1: int
        num2: int
    
    
    n1 = BigNumber(10, 1)
    n2 = BigNumber(9, 1)
    print(n1 > n2)

    불변성 (불변 객체)

    자바의 Record처럼 파이썬에서 불변 객체를 지원하고 싶다면 @dataclass의 forzen 옵션을 이용하면 된다.

     

    • 각 필드에 선언된 객체 자체를 바꾸는 것은 안됨. (Freeze)
    • 각 필드에 선언된 객체가 가변 객체인 경우, 가변 객체의 상태는 변경 가능함. 예를 들어 list에 값을 계속 append 할 수 있음. 
    • frozen=True면 불변 객체가 되기 때문에 해시가 가능해진다. 즉, 딕셔너리의 Key로 사용할 수 있게 된다.
    from dataclasses import dataclass
    
    @dataclass(frozen=True)
    class BigNumber:
        num1: int
        num2: int
        num_list: list
    
    n1 = BigNumber(1,2, [])
    
    # 이건 안됨. (불변 객체)
    n1.num1 = 10
    
    # 이건 됨.
    n1.num_list.append('hello')

     


    6. 다른 클래스와 비교

    튜플, 딕셔너리의 한계점이 존재하기 때문에 이럴 경우에 데이터 클래스를 사용한다고 했다. 아래에서는 몇 가지 데이터 타입을 더 비교해 볼 생각이다.

    • 딕셔너리
    • TypedDict
    • NamedTuple

    기본적으로는 데이터 클래스를 더 사용하는 것이 좋다. 그 이유는 데이터 클래스는 다른 타입들에 비해서 다음 장점을 제공해주기 때문이다.

    • 타입 어노테이션을 명시적으로 할 수 있음. (딕셔너리, NamedTuple은 불가능)
    • 불변성, eq, 정렬 등을 지원함. (NamedTuple, TypedDict 지원하지 않음) 
    • 내부에서 사용가능한 함수 구현 가능함. (NamedTuple 지원하지 않음) 

     

     

    딕셔너리 vs 데이터 클래스

    from typing import Union
    from dataclasses import dataclass
    
    # 딕셔너리 타입이 가독성이 좋은가?
    my_dict: dict[str, Union[str, int]] = {
        'name': 'Tom',
        'age': 10}
    
    @dataclass
    class MyDict:
        name: str
        age: int
    • 딕셔너리는 내부에 어떤 Key가 존재하는지 알 수 없음.
    • 딕셔너리는 각 키에 대한 값이 무슨 타입인지 알 수 없음. 타입힌트도 불가능함. 

     

     

    TypedDict vs 데이터 클래스

    # 데이터 클래스 사용
    @dataclass
    class MyDict:
        name: str
        age: int
    
    MyDict('hello', 10)
    
    # TypedDict 사용
    from typing import TypedDict
    class MyDict2(TypedDict):
        name: str
        age: int
    
    MyDict2(name='hello', age=10)

    TypedDict와 데이터 클래스는 대동소이하다. 기본적으로는 데이터 클래스를 사용하는 것이 좋다. 이유는 다음과 같다

    • 데이터 클래스는 불변성, 호환성, eq, 대소비교를 지원해준다.

     

     

    NamedTuple vs 데이터 클래스

    @dataclass
    class MyDict:
        name: str
        age: int
    
    MyDict('hello', 10)
    
    from collections import namedtuple
    
    MyDict3 = namedtuple('MyDict3', ['name', 'age'])
    a = MyDict3('hello', 10)
    a.name

    NamedTuple은 튜플의 각 인덱스에 필드를 지정해주어서 명시적으로 튜플의 필드에 접근할 수 있도록 지원해준다. 잘 사용할 수 있는 녀석이긴 하지만 데이터 클래스를 일반적으로 더 자주 사용하는 것이 좋다. 이유는 다음과 같다. 

    • 데이터 클래스는 각 필드에 대한 타입을 지정할 수 있다. (명시적 타입 힌트) 
    • 데이터 클래스는 불변성, 호환성, 동일성 검사를 지원한다.
    • 데이터 클래스 내부에서 함수를 구현할 수도 있다. 

     

     

    댓글

    Designed by JB FACTORY