단단한 파이썬 4. 타입의 제어

    들어가기 전

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

     


    고급 타입 어노테이션

    기본적인 타입 어노테이션만을 이용하는 것은 한계가 존재한다. 파이썬에서는 고급 타입 어노테이션을 제공하는데, 이것을 이용하면 좀 더 단단한 코딩을 할 수 있다.

    • Optional : Union[None, 내 타입]을 의미함.
    • Union : 여러 타입들이 선택될 수 있음을 의미
    • Literal : 개발자가 특정 값들만 사용하도록 제약
    • Annotated : 여러분의 타입에 추가적인 설명을 제공하고자 사용
    • NewType : 특정 상황에서 타입을 제한하고자 사용
    • Final : 불변 타입임을 알려줌. 

    아래에서는 하나씩 내용을 살펴보고자 한다. 

     

     


    Optional 타입

    Optional 타입을 정리하면 다음과 같다.

    • Optional은 Union[내 타입, None]을 의미함. 
    • 이 변수를 사용하는 사람이 명시적으로 None에 대해서 처리해야함을 알려줌. 
    • Collection에 None을 사용하면 더욱 의미가 명확함.
      • 비지 않은 리스트 : 가용한 데이터가 있음.
      • 빈 리스트 : 가용한 데이터가 없음.
      • None : 오류 케이스
    • 타입 체커를 통해서 타입체크를 하는 경우, 좀 더 디테일한 내용을 알려줌. (아래 코드에서 확인) 
    from typing import Optional
    
    class Bun:
        def __init__(self, name: str):
            self.name = name
            self.ham: list[str] = []
    
        def add_ham(self, ham: str):
            self.ham.append(ham)
    
    
    def not_available():
        return True
    
    
    def get_bun() -> Optional[Bun]:
        if not_available():
            return None
        return Bun('Wheat')
    
    
    def create_hot_dog():
        bun = get_bun()
        bun.add_ham("ham")

    파이썬의 Optional은 이 변수를 사용하는 사람이 None(null)에 대해서 대응해야 함을 명시적으로 알려주는 역할을 한다. 여기서 mypy를 어떻게 실행하느냐에 따라 여러가지 결과가 나오는 것을 확인할 수 있다.

    $ mypy --strict 4-1.py   
    >>>
    ...
    4-1.py:24: error: Item "None" of "Optional[Bun]" has no attribute "add_ham"  [union-attr]
    
    $ mypy 4.1py
    >>>
    Success: no issues found in 1 source file

    첫번째 결과는 get_bun()의 결과 Optional[Bun]이 반환되는데, 이 때 bun이 None이라면 add_ham()이라는 Attribute가 없음을 의미한다. 만약 이런 에러를 해결하고 싶다면 None 케이스를 처리하는 코드를 작성해야한다.

    def create_hot_dog():
        bun = get_bun()
        if bun is None:
            return
        bun.add_ham("ham")
        
        
    $ mypy --strict 4-1.py   
    >>>
    ...
    Success: no issues found in 1 source file

    None에 대한 방어적 코드를 작성하면 이 문제가 해결되는 것을 확인할 수 있다. 

     


    Union 타입

    • Union 타입은 한 변수에 여러 타입이 올 수 있음을 의미함. 
    • Union을 이용한 다중 상속을 이용하지 않고도 간단히 타입 어노테이션을 할 수 있음. Union[Hotdog, Pretzel]
    • Union의 Usecase
      • 사용자 입력에 따라 서로 다른 반환 타입을 가질 때
      • 사용자 입력으로 다른 형태의 타입을 받을 수 있을 때 
      • 이전 버전과의 호환성을 위해 다른 타입을 반환함. 
      • 하나 이상의 값을 어쩔 수 없이 다뤄야 하는 경우 
    from typing import Union
    
    
    class Hotdog:
        def __init__(self):
            print('Hotdog')
    
    
    class Ham:
        def __init__(self):
            print('Ham')
    
    
    def get_func(name: str) -> Union[Hotdog, str]:
        if name == 'Hotdog':
            return Hotdog()
        if name == 'Ham':
            return Ham()
        return 'Other'

    Union 타입은 하나의 변수에 여러 가지 타입이 올 수 있을 때 주로 사용한다. 위에서는 반환값으로 Hotdog, str 타입이 올 수 있음을 의미한다. 

    $ mypy 4-2.py
    4-2.py:15: error: Incompatible return value type (got "Ham", expected "Union[Hotdog, str]")  [return-value]
    Found 1 error in 1 file (checked 1 source file)

    타입 체커를 실행해보면 경고가 발생하는 것을 알 수 있다. Hotdog, str 타입만 반환할 수 있는데 이 과정에서 Ham 타입이 반환되는 경우도 존재하기 때문이다. 

     


    Literal 타입

    • Literal 타입은 매개변수의 값을 특정 범위로 제한하는데 사용한다.
    from typing import Literal
    from dataclasses import dataclass
    
    
    @dataclass
    class Error:
        error_code: Literal[1, 2, 3, 4, 5]
        diposed_of: bool
    
    
    Error("a", True)
    Error(1, True)
    
    
    

    위의 코드가 Literal의 예시가 된다.

    $ mypy 4-3.py
    4-3.py:11: error: Argument 1 to "Error" has incompatible type 
    "Literal['a']"; expected "Literal[1, 2, 3, 4, 5]"  [arg-type]
    Found 1 error in 1 file (checked 1 source file)

    위 코드에 대해서 타입체커를 실행해보면 다음 결과가 나온다. 1,2,3,4,5 중에 하나의 값만 나와야 하는데 "a"라는 값이 포함되었기 때문에 문제가 된다는 것을 타입체커가 발견한다. 

     


    Annotated 타입

    • 변수에 가능한 값을 수백개로 제한할 때, Literal에 일일이 모든 값을 작성하기는 어렵다. 가독성도 좋지 않고 낭비 일 것이 분명하기 때문이다. 
    • Literal은 문자열의 크기, 특정 정규 표현에 맞게 제약을 할 수는 없음. 
    • Annotated 타입을 위한 타입체커가 존재하지 않기 때문에 문제를 검출할 수는 없음. (알려주는 용도로 사용함)
    from typing import Annotated
    
    def ValueRange(startInt, endInt):
        return True
    
    x: Annotated[int, ValueRange(3, 5)]
    y: Annotated[int, "범위는 3부터 5까지만 가능."]
    

     

     


    NewType

    Annotated 타입을 위한 타입체커는 존재하지 않다. Annotated 타입을 써야하고 타입 체커를 사용하고 싶다면 NewType을 사용하는 것도 좋은 방법이 될 수 있다. 

    • NewType은 기존의 타입을 받아 동일한 필드 + 메서드를 갖는 새로운 타입을 생성함. 
    • NewType은 묵시적 타입 전환을 허용함. NewType은 단방향 타입 변환만 지원함. 
      • 기존 타입 -> 새로운 타입은 전환 안됨.
      • 새로운 타입 -> 기존 타입은 전환됨. 
    • NewType의 묵시적 단방향 타입 전환 지원 덕분에 다음 UseCase를 가질 수 있음.
      • User 객체와 LoggedInUser 객체를 분리해서 추적한다. NewType('LoggedInUser', User)
      • 유효 사용자 ID를 나타내야 하는 정수 값을 추적한다. 사용자 ID를 NewType으로 제한함으로써 If문의 사용 없이 일부 함수가 유효한 ID로만 동작하게 할 수 있음.
    from typing import NewType
    
    
    class Hotdog:
        ''' 준비가 안된 핫도그를 나타냄. '''
    
    # NewType 생성. 클래스 생성된 것처럼 보고 생성자로 생성하면 됨. 
    ReadyHotdog = NewType('ReadyHotdog', Hotdog)
    
    
    def server_to_customer1(hotdog: ReadyHotdog):
        pass
    
    
    def server_to_customer2(hotdog: Hotdog):
        pass
    
    my_hotdog = Hotdog()
    my_ready_dog = ReadyHotdog(my_hotdog)
    
    
    ### 단방향 타입 전환만 가능
    server_to_customer1(my_hotdog) # 오류 발생
    server_to_customer1(my_ready_dog) # 통과
    
    server_to_customer2(my_hotdog)
    server_to_customer2(my_ready_dog)
    • NewType() 메서드를 이용하면 해당 타입을 한번 더 랩핑한 새로운 타입을 만들어 낼 수 있다. 위 코드에서는 Hotdog를 한번 더 랩핑해서 ReadyHotDog라는 새로운 타입을 생성한다.
    • NewType으로 생성된 타입은 타입 어노테이션 관점에서 단방향 전환을 지원한다. 이 말은 Hotdog -> ReadyHotdog로는 안되지만, ReadyHotdog -> Hotdog는 지원한다는 것을 의미한다. 
    $ mypy 4-5.py
    4-5.py:21: error: Argument 1 to "server_to_customer1" has incompatible type "Hotdog"; expected "ReadyHotdog"  [arg-type]
    Found 1 error in 1 file (checked 1 source file)

    타입 체커를 실행해보면 단방향 타입 전환만 지원하는 것을 알 수 있다. 위 코드에서 오류 발생 부분에서 타입체커의 경고가 불을 뿜는다. 

    from typing import NewType, Union
    
    class User:
        def __init__(self, name: str, login: bool):
            self.login = login
            self.name = name
    
    LoginUser = NewType('LoginUser', User)
    
    # LoginUser 객체를 생성하는 메서드를 작성.
    def login(user: Union[User, LoginUser]
              ) -> Union[User, LoginUser]: 
        if user.name == 'login':
            return LoginUser(user)
        return user

    NewType과 타입 체커를 적극적으로 이용하기로 했으면, NewType의 객체를 생성하는 메서드를 하나 만들어야 한다. 이런 메서드에서는 필요한 유효성 검사를 한 후에 객체를 만들어주는 것이다. 한 가지 문제점은 파이썬에서는 자바처럼 private 접근 지시자가 없기 때문에 강제할 수 없다는 것이다. 즉, deligation을 통해 의도와는 다르게 사용될 가능성이 남아있다는 것을 인지해야한다.

     

     


    Final 타입

    불변 타입을 나타내기 위해서 Final을 사용할 수 있음. 

    • 타입 체커는 Final에 대한 타입 체킹을 처리해준다. 
    • Final은 모듈처럼 변수의 사용 범위가 매우 넓을 때 많이 사용됨.
    from typing import Final
    
    def display_vendor():
        VENDOR_NAME: Final = "My Name"
        VENDOR_NAME += "A"

    위는 불변 타입 어노테이션 Final을 이용한 코드다. 

    $ mypy 4-7.py
    4-7.py:4: note: By default the bodies of untyped functions are not checked, consider using --check-untyped-defs  [annotation-unchecked]
    4-7.py:5: error: Cannot assign to final name "VENDOR_NAME"  [misc]
    Found 1 error in 1 file (checked 1 source file)

    위 코드에 대해 타입 체커를 실행해보면 final name의 값을 바꿀 수 없다는 에러가 발생하는 것을 확인할 수 있다.

     


    타입 별칭

    필요한 경우 타입 별칭을 이용할 수도 있다. 타입 별칭을 이용하는 것이 가독성이 더 좋다면 타입 별칭을 적극적으로 사용해 볼 수도 있다. 그러나 타입 별칭을 찾아보기 위해서 한번 더 코드 점프를 해야하는 단점이 있으므로 이런 것들을 잘 고려해봐야 한다.

    from typing import Union
    
    # 타입 별칭 붙이기. 
    IdOrName = Union[int, str]
    
    def is_Id_or_name(my_string: IdOrName):
        pass

    댓글

    Designed by JB FACTORY