단단한 파이썬 5. 컬렉션 타입

    들어가기 전

    이 글은 단단한 파이썬 5장을 공부하며 작성한 글입니다.


    컬렉션의 어노테이션

    def get_ages(humans: list):
        pass

    humans라는 매개변수가 주어지고, 이 매개변수가 list라는 타입 어노테이션을 봤다고 하자. 그렇다면 그 다음으로 우리가 할 수 있는 것은 무엇일까? list의 요소가 어떤 타입인지 알지 못한다면, 어떤 처리를 해야하는지나 어떤 메서드를 호출해야하는지 알 수 없다. 이것은 잠재적인 런타임 에러를 보여준다.

    class Human:
        def add(self):
            pass
    
    # list 컬렉션 요소로 어떤 타입이 오는지 타입 어노테이션 처리. 
    def get_ages(humans: list[Human]):
        pass

    위 코드처럼 list의 요소로 어떤 타입이 오는지를 명시해주면 개발하기가 좀 더 편리해 질 것이다. 이것은 컬렉션에 어노테이션을 통해서 달성할 수 있다.  컬렉션 어노테이션은 다음과 같이 처리할 수 있다.

    # 컬렉션의 타입 어노테이션. 
    humans1 = list[Human]
    humans2 = dict[str, Human]
    humans3 = set(Human)

     


    동종 컬렉션 / 이종 컬렉션

    동종 컬렉션과 이종 컬렉션은 다음을 의미한다.

    • 동종 컬렉션 : 컬렉션 내의 모든 요소가 하나의 타입을 가짐.
    • 이종 컬렉션 : 컬렉션 내의 요소들이 서로 다른 타입을 가짐. 예를 들면 ["a", 1]

     

    from typing import Union
    
    Ingredient = tuple[str, float, str]
    Recipe = Union[int, Ingredient]
    
    def adjust_recipe(recipe: list[Recipe]):
        """
        :param recipe: 리스트임
            첫번째 항목은 인원수 (int)
            두번째 항목부터 ("flour,", 1.5, "cup") 같은 튜플임.
        """
        pass
    

    recips는 list인데, 여러 타입이 올 수 있는 이종 컬렉션이다. 이 이종 컬렉션은 다음과 같은 특징을 가진다.

    • 첫번째 인수는 반드시 int
    • 두번째 인수부터는 튜플 

    첫번째 인수가 '특수한 경우'를 파악할 수 있는 방법은 없지만, 타입 힌트를 통해서 이종 컬렉션에 올 수 있는 타입을 한정하는 형식으로 처리할 수는 있다. 위 코드처럼 처리하면 첫번째 항목은 int, 나머지는 tuple[str, int, str] 타입이 와야한다는 것을 타입체커를 통해서 잡아낼 수 있다.  

    이런 특수한 이종 컬렉션은 하나의 컬렉션에 담기보다는 매개변수를 분리해서 전달해주는 것이 좀 더 깔끔한 접근법이 될 것이다. 이종 컬렉션의 잘못 사용된 예시인데, 아래에서는 이종 컬렉션을 적극적으로 사용하는 예시도 볼 수 있다. 

    People = tuple[str, int] # name, age  
    

    이렇게 사용되는 튜플은 이종 컬렉션이다. 이런 종류의 이종 컬렉션은 자바의 Record처럼 불변 타입을 잘 나타내 줄 수 있기 때문에 적극적으로 사용하는 것도 좋을 것이다. 

     


    TypedDict의 필요성

    People = tuple[str, int] # name, age
    
    person1: People = ("Tom", 1)
    person2: People = ("Hardy", 2)
    
    # 튜풀은 인덱스로 접근해야 함.
    print(person1[0])
    print(person1[1])
    
    

    앞서서 위와 같은 형태의 이종 컬렉션 튜플을 사용하는 것은 좋은 예시가 될 수 있다고 했다. 그러나 튜플의 각 요소에 접근할 때는 인덱스로 접근해야하는데, 각 인덱스가 어떤 값을 의미하는지를 잘 모르게 된다. 이런 경우라면 튜플 대신 딕셔너리를 이용하는 것이 좋을 것이다.

    from typing import Union
    
    PersonDict = dict[str,  Union[str, int]]
    person_dict: PersonDict = {
        "name": "Tom",
        "age": 10
    }

    튜플의 위 단점을 극복하기 위해 다음과 같이 딕셔너리와 타입 힌트를 적극적으로 이용해 볼 수도 있다. 그러나 딕셔너리 자체도 명백한 한계를 가진다.

    • 거대한 딕셔너리에는 value로 여러 타입이 올 수 있음. 즉, Union에 명시되어야 할 것들이 많아짐. 
    • 사용자는 "name"의 반환값이 str인지 int인지 모름. 
    • 사용자는 딕셔너리에서 어떠한 키가 유효한 것인지 모름. 예를 들어 full_name도 가능하다고 생각할 수 있음. 

    이런 단점들을 극복하기 위해서 typing 모듈이 제공하는 TypedDict를 이용해 볼 수 있다.

    from typing import TypedDict
    
    class Range(TypedDict):
        min: float
        max: float
    
    
    class NutritionInformation(TypedDict):
        value: int
        unit: str
    
    def test() -> int:
        my_dict: Range = {"min": 10.0, "max": 50.0}
        return my_dict['min']

    다음은 TypedDict를 이용해서 타입 힌트를 주는 방법이다. 

    • TypedDict를 선언할 때, dict에 어떤 키값이 존재하고, 각 키 값에 대한 Value가 어떠한 값인지를 선언할 수 있음. 
    • IDE에서는 어떤 key가 존재하는지를 자동완성으로 알려줌. 
    • 딕셔너리에서 Value값을 이용할 때 어떤 타입인지를 타입 체커를 통해 체크할 수 있음.
    $ mypy 5-3.py
    >>>
    5-3.py:33: error: Incompatible return value type (got "float", expected "int")  [return-value]
    Found 1 error in 1 file (checked 1 source file)

    위 코드에서 대해서 타입체커를 실행한 결과는 다음과 같다. 함수의 반환 타입은 int인데 'min'의 Value 타입이 Float이다. 타입이 맞지 않는 것을 확인한 후 타입 체커는 이것에 대해서 경고를 알려준다.

     


    제네릭 사용하기

    자바의 제네릭처럼 파이썬에서는 제네릭을 사용할 수 있다. 물론 동적 언어이기 때문에 전적으로 타입 체커에 의존해야한다는 점은 있다.

    from typing import TypeVar, Generic
    
    T = TypeVar('T')
    def f1(value: list[T]) -> list[T]:
        return value[::-1]
        # return ['a'] # 이건 타입 체킹 시 에러 발생함. 
    
    f1('a')

    TyepVar()를 이용해서 타입 변수를 생성할 수 있다. 위의 함수의 경우 'T' 타입의 매개변수를 받아서 'T' 타입의 값을 반황해야하는 것을 의미한다. 

    • return value : 타입체킹 성공함.  T 타입을 받아서 T 값을 돌려주기 때문임.
    • return 'a' : 타입체킹 실패함. f1('a')에서는 성공할 것처럼 보이지만, f1(1)이 오는 경우 T는 int가 됨. 따라서 타입 체킹이 실패함. 

    TypeVar는 이처럼 함수에서 매개변수, 반환 값들에 대한 타입을 표현하기 위해서 사용할 수 있다. 

    from typing import TypeVar, Generic
    
    T = TypeVar('T')
    V = TypeVar('V')
    Z = TypeVar('Z')
    
    
    class MyClassTest(Generic[T, V]):
    
        def __init__(self,
                     my_value_T: T,
                     my_value_V: V):
            self.my_T = my_value_T
            self.my_V = my_value_V
    
        def get_data(self) -> T:
            return self.my_T
    
        def get_data2(self) -> V:
            return self.my_V
    
        def return_value(self, name: Z) -> Z:
            return name

    클래스에서 제네릭을 이용할 때는 Generic[...]을 상속받은 클래스를 이용하면 멤버 메서드에서 제네릭 타입을 표기해 줄 수도 있다.

    $ mypy 5-5.py
    >>> 
    5-5.py:16: error: Incompatible types in assignment (expression has type "list[<nothing>]", variable has type "V")  [assignment]
    Found 1 error in 1 file (checked 1 source file)

    제네릭 타입을 사용했는데, 만약 잘못된 타입이나 제네릭 타입을 반납한다면 타입 체커가 다시 한번 불을 뿜는다. 

     


    제네릭의 또다른 용도 

    제네릭이 컬렉션에서 일반적으로 많이 사용되지만, 컬렉션이 아닌 타입에서도 제네릭을 효과적으로 이용할 수 있다. 아래 경우가 적절한 예시가 될 수 있다.

    from typing import Union
    
    class NutritionInfo:
        pass
    
    class ApiError:
        pass
    
    # 여러 중복된 반환 타입이 있는 경우, 제네릭으로 표현할 수 있음.
    def get_a(value: str) -> Union[NutritionInfo, ApiError]:
        pass
    
    def get_b(value: str) -> Union[list[str], ApiError]:
        pass

    두 개의 함수가 있고, 각 함수의 호출 결과로 다음 타입들을 반환한다고 가정해보자. 주목할만한 점은 Union[T, ApiError] 라는 형태로 반환이 된다는 것이다. 이런 경우일 때, 제네릭을 이용하면 코드 반복을 줄여줄 수 있다. 

    from typing import TypeVar
    T = TypeVar('T')
    ResponseType = Union[T, ApiError]
    
    def get_a_improve(value: str) -> ResponseType[NutritionInfo]:
        pass
    
    def get_b_improve(value: str) -> ResponseType[list[str]]:
        pass
    • 다음은 제네릭을 이용해서 Union[T, ApiError]를 ResponseType이라는 별칭으로 줄여준 것이다. 이 때, 제네릭 'T'를 이용하는데, ResponseType[] 안에 사용한 제네릭의 타입을 명시해주면 된다. 
    • 예를 들어 ResponseType[NutritionInfo] 같은 형태로 가능함.

     

     

     


    기존 타입의 변경

    T = TypeVar('T')
    
    #  제네릭은 나만의 컬렉션 타입에 유용함. 
    def f(v: list[T]) -> T:
    	pass

    앞서서 제네릭을 이용할 때는 나만의 타입을 가지는 컬렉션 타입을 만들 때 유용했다. 이미 존재하는 컬렉션의 동작을 수정하거나 추가하려면 제네릭을 이용하는 것이 아니라, 컬렉션을 상속받아서 새로운 컬렉션을 구현해야한다. 그러나 여기서 주의해야 할 점이 있다.

    • 내장 컬렉션을 상속받아서 다른 메서드를 추가하는 경우는 그냥 사용해도 괜찮음.
    • 내장 컬렉션의 메서드를 오버라이딩해서 사용하는 경우는 collections.UserDict, collections.abc.Set 같은 것들을 사용해야 함.

    dict, set, list 같은 내장 컬렉션을 성능을 올리기 위해서 일반적으로 각 메서드들이 '인라인 코드'를 실행하도록 동작한다. 이것은 내장 메서드를 오버라이딩 하더라도, 다른 내장 메서드들이 서로를 호출하지 않는 이상 새로운 클래스가 원하는 방식으로 동작하지 않을 수도 있음을 의미한다. 

    예를 들어 딕셔너리는 키에 대한 값을 가져올 때 __getitem__()이라는 메서드를 이용한다. 그런데 딕셔너리의 다른 비슷한 기능들에서 __getitem__()을 호출할 것이라 예상했지만, 실제로는 __getitem__()을 호출하지 않을 수도 있다. 앞서 이야기 한 것처럼 각 내장 메서드들은 성능을 위해 인라인 코드를 호출하기 때문이다.

    이런 이유 때문에 기존 타입의 내장 메서드를 오버라이딩해서 새로운 동작을 추가하는 경우 내장 컬렉션을 상속 받는 것이 아니라 collections에서 제공하는 abc 모듈, 혹은 UserDict 같은 것들을 상속해야한다. 

     


    예시 : collections.UserDict

    내장 컬렉션의 내장 메서드를 수정하고 싶다면 collections에서 제공하는 UserDict 같은 클래스들을 상속받는 것이 적합하다. 

    • 비슷한 녀석들은 UserString, UserList가 있음.
    • 컬렉션에 있는 모든 데이터는 self.data에 보관되어 있음.
    from collections import UserDict
    
    def get_aliases(key):
        if key == 'hello':
            return ['ballo', 'abc', 'qwer']
        
    
    class InformationDict(UserDict):
        def __getitem__(self, key):
            try:
                return self.data[key]
            except KeyError as _:
                pass
            
            for alias in get_aliases(key):
                try:
                    return self.data[alias]
                except KeyError as _:
                    pass
            raise KeyError(f'Could not find {key} or any of its aliases')

    위는 UserDict 클래스의 __getitem__() 내장 메서드를 오버라이딩 경우다. 일반적인 Dict는 Key가 없으면 문제가 된다. InformationDict는 Key로 값을 찾을 수 없는 경우, 그 key 값에 대한 별칭들을 가져오는 함수 get_aliases()를 호출해서 별칭에 대한 값이 존재하는지까지 확인하도록 동작한다. 

    이런 식으로 __getitem__() 내장 메서드를 오버라이딩 해서 동작을 바꾸는 것을 원한다면 내장 자료형을 상속 받는 것이 아니라 UserDict 같은 것들을 상속 받아야 한다.

     


    collections.abc 이용해서 나만의 컬렉션 구현

    UserDict, UserList 같은 것들을 제공되지만 UserSet 같은 것들은 제공되지 않는다. 만약 Set의 내장 메서드를 오버라이딩 해야 할 상황이 있다면 어떻게 해야할까? 이 때는 collections.abc.Set을 상속받으면 된다. 상속받은 후 추상 메서드들을 구현해줘야 하는데, collections.abc에서 각각 어떤 추상 메서드를 구현해야 할지는 아래 문서에 나와있다.

    https://docs.python.org/3/library/collections.abc.html#collections-abstract-base-classes

    만약 collections.abc.Set을 상속받아서 구현해야 한다면 다음 추상 메서드를 구현해야한다. 

    • __contains__()
    • __iter__()
    • __len__()
    import collections.abc
    
    def get_aliases(text):
        if text == 'rocket':
            return ['arugula']
    
    
    class AliasedIngredients(collections.abc.Set):
        def __init__(self, ingredients: set[str]):
            self.ingredients = ingredients
    
        def __contains__(self, value: str):
            return value in self.ingredients or any(alias in self.ingredients for alias in get_aliases(value))
    
        def __iter__(self):
            return iter(self.ingredients)
    
        def __len__(self):
            return len(self.ingredients)
    
    
    ingredients = AliasedIngredients({'arugula', 'eggplant', 'pepper'})
    for ingredient in ingredients:
        print(ingredient)
    
    
    assert ingredients == {'arugula', 'eggplant', 'pepper'}
    
    assert len(ingredients) == 3
    
    assert 'arugula' in ingredients
    assert 'rocket' in ingredients

    위에서는 rocket에 대한 별칭을 arugula로 등록하고, 'argula' in ingredients라는 메서드가 True가 되도록 동작한다.

    • 일반적인 Set인 경우 ingredients에는 rocket이 없기 때문에 false가 발생할 것임.
    • 그러나 __contains__() 메서드를 오버라이딩 해서 조금 다르게 동작하도록 구현함. 

    이 내용은 내장 자료형의 내부 메서드를 오버라이딩 해야할 필요가 있다면 collections.abc에서 제공되는 클래스들을 상속받아서 구현해야한다는 것을 의미한다.


    collections.abc를 이용해 덕타이핑 의도 전달

    • 파이썬에서 덕타이핑은 하나의 함수의 인자로 여러 가지 타입이 올 수 있다는 자유로움을 제공한다. 그러나 이런 자유로움은 단점이 존재한다. 
    • 반면 덕타이핑 때문에 매개변수에 너무 많은 타입이 올 수 있다는 것은 이 함수의 사용자들에게 함수의 의도를 잘 전달하지 못함을 의미함.

    이럴 때는 collections.abc를 이용해 타입 힌트를 제공할 수 있고, 이를 통해 함수 사용자들에게 보다 의도를 명확하게 전달할 수 있다.

    #  매개변수 이름으로만 Iterable을 추론해야 함. 덕타이핑 100% 의존.
    def print_items(items):
        for item in items:
            print(item)
            
    # 타입힌트로 Iterable을 추론할 수 있음.
    def print_items(items: collections.abc.Iterable):
        for item in items:
            print(item)

    첫번째 코드에서는 items라는 이름만 보고 Iterable한 것이겠지라고 추론할 것이다. 즉, 덕타이핑에 100% 의존하는 상태인데 만약 items가 items.item_list라는 곳에 iterable한 값을 가지고 있다면 이 함수는 런타임 에러가 발생할 수도 있다. 

    이런 덕타이핑에 의한 단점을 조금 보완하고자 collections.abc.Iterable을 사용할 수도 있다. 타입 체커를 사용하지 않는 이상 이 함수에 어떠한 값도 올 수 있다는 덕타이핑적 사고에는 변함이 없다. 장점은 그대로 유지하되 Iterable한 객체가 와야한다는 타입 힌트를 알려주면서 사용자들에게 '함수의 의도'를 더 잘 전달할 수 있게 된다. 

     


    요약

    • 컬렉션에도 타입 힌트를 줄 수 있음.
    • 이종 타입의 컬렉션을 사용하는 경우, 다른 개발자들의 추론을 도울 수 있도록 TypedDict 같은 것들로 충분히 의도를 주면 좋음. 
    • TypeVar와 Generic을 이용해 제네릭을 누릴 수도 있다.
    • 컬렉션을 생성할 때 다음 옵션을 생각하라.
      • 메서드 추가하는 경우 내장형 타입을 상속 받아서 쓸 수 있음. 그러나 내장 메서드를 오버라이딩 하는 경우는 내장형 타입을 상속받지 마라. 의도치 않게 동작할 수 있음.
      • 리스트, 딕셔너리, 문자열 등에서 작은 부분만 변경한다면 UserList, UserDic,t, UserString을 상속받아서 사용해라. 각 타입의 저장소는 self.data로 접근해야함. 
      • 그 외 컬렉션의 인터페이스로 더 복잡한 클래스를 작성하려면 collections.abc를 사용하라.

    댓글

    Designed by JB FACTORY