견고한 Python - 2일차 Constaining types

0

robust_python

목록 보기
2/5

Constaining types

기본적인 type들 말고도 다양한 type들을 사용해야할 때가 있다. python에서는 좀 더 advanced한 type을 제공하는데, 이를 통해서 type의 사용을 엄격히 제한하여 원치않는 에러의 발생을 줄 일 수 있다.

간단히 정리하면 다음과 같다.
1. Optional: None references를 대체할 때 사용
2. Union: type들 중 선택을 할 때 사용
3. Literal: 특정 값으로 제한할 때 사용
4. Annotated: type에 대한 추가적인 설명을 제공할 때 사용
5. NewType: 특정 context로 type을 제한할 때 사용
6. Final: 값이 새로운 값으로 바인딩되지 않도록 하기위해 사용

먼저 Optional type부터 알아보도록 하자.

1. Optional Type

c/c++에는 null pointer가 존재한다. 이는 매우 치명적인 에러를 발생시키는데, 문제는 디버깅도 어려워서 발생해도 찾기 힘들다. python에는 null pointer가 없지만, None이라는 것이 있다. python에서도 c/c++의 null pointer처럼 None을 잘못사용하는 경우, 걷잡을 수 없는 에러가 발생하기 쉽다.

따라서 None이 발생할 수 있는 모든 곳에는 None 검사를 해야하는데, 문제는 None은 모든 type에 호환된다는 것이다. 따라서, 다음처럼 모든 부분에 None 검사를 해야하면 코드가 매우 이상해져 버린다.

def create_hot_dog():
    bun = dispense_bun()
    if bun is None:
        print_error_code("Bun unavailable. Check for bun")
        return

    frank = dispense_frank()
    if frank is None:
        print_error_code("Frank was not properly dispensed")
        return

    hot_dog = bun.add_frank(frank)
    if hot_dog is None:
        print_error_code("Hot Dog unavailable. Check for Hot Dog")
        return

    ketchup = dispense_ketchup()
    mustard = dispense_mustard()
    if ketchup is None or mustard is None:
        print_error_code("Check for invalid catsup")
        return

    hot_dog.add_condiments(ketchup, mustard)
    dispense_hot_dog_to_customer(hot_dog)

그럼 이러한 문제를 해결하기위해서 try-except를 사용하면 어떨까? 사실 나쁘지 않은 방법이지만, 좋지는 않다. Exception을 통한 해결은 예기치 않은 문제가 발생했을 때를 해결하는 방법으로 None type에 대한 처리로 사용하려면 모든 부분에 넣어주어야 한다.

그렇지만 모든 함수들이 반환값으로 None을 반환할 수 있는 것은 아니다. 특정 함수는 None을 반환하지 않고, 빈 값이 들어있는 type을 반환하는 경우도 많다. 이러한 함수들까지 Exception으로 처리하도록 만드는 것은 사용자에게 너무 큰 부담을 주는 것이다.

따라서, 함수에서 특정 type이 아니라 None이 반환값으로 사용될 수 있다는 것을 알려주어야 하며, 이를 토대로 사용자가 Exception을 사용하거나 None인 경우를 if문으로 처리하거나 할 수 있게해주어 사용자의 부담을 줄여주어야 한다.

이를 가능하게 해주는 것이 바로 Optional type이다. Optional type은 해당 변수가 값을 가질지 아닐 지 둘 중 하나를 선택할 수 있도록 해준다.

from typing import Optional
maybe_a_string: Optional[str] = "abcdef"
maybe_a_string: Optional[str] = None

maybe_a_stringOptional type으로 str또는 None을 type을 가진 value를 받을 수 있다.

함수 시그니처의 반환타입으로 Optional을 사용하게되면, 해당 함수가 None을 리턴할 수 있다는 것을 알려주기 때문에, 사용자 입장해서 분명하게 개발자와 소통할 수 있다.

def dispense_bun() -> Optional[Bun]:
# ...

만약 사용자가 dispense_bun 함수의 시그니처를 확인하게되면 해당 함수가 반환값으로 None을 반환한다는 것을 알 수 있다.

또, Optional을 사용하면 list의 상황에서도 좋은 점이 있는데, Optional[list]의 경우는 다음과 같은 경우들로 생각해볼 수 있다.
1. list에 값이 있다.
2. list는 있지만 안에 앖은 없다.
3. list가 None이다.

만약 Optional로 쓰여있지 않고 list만 반환한다고 한다면, list가 비어있는 것과 None인 것과 구분하기가 어려울 것이다. Optional로 써주면 None도 가능하기 때문에 list가 아예 없는 것도 상정할 수 있는 것이다.

Optional type을 사용하면 typecheckers가 detect할 수 있으며, 이를 통해 Optional이 없다면 None value가 사용되지 않도록 할 수 있다.

def dispense_bun() -> Bun:
    if not are_buns_available():
        retrn None
    return Bun("what")

다음의 코드의 경우, typechecker로 검사하면 에러가 발생한다.

error: Incompatible return value type (got "None", expected "Bun")

이는 dispense_bun의 반환 타입으로 None을 허락하지 않는다는 것이다. -> Bun부분을 Optional[Bun]으로 변경하면 None을 반환할 수 있으므로 문제가 해결된다.

또한, dispense_bun를 사용하는 client 측면에서도 typechecker가 가능하다. 즉, 사용했을 때 반환값으로 None이 나올 수 있기 때문에, 이를 확인하라는 메시지가 나온다.

def create_hot_dog():
    bun = dispense_bun()
    hot_dog = bun.add_frank(frank)

error: Item "None" of "Optional[Bun]" has no attribute "add_frank"

이는 dispense_bun의 반환 타입이 Optional[Bun]이기 때문에 bun 변수가 None일 가능성이 있다는 것이다. bun 변수가 None이라면 add_frank가 없기 때문에 에러가 발생할 것이다. if문으로 None일 때를 따로 처리해주면 해당 에러가 사라지게 될 것이다.

2. Union Types

Union type은 하나의 변수에 여러 개의 타입들이 사용 가능할 때 사용하는 type이다. 가령 Union[int, str]int또는 str 타입의 값이 변수에 할당 가능하다는 것이다.

다음은 HotDog 또는 Pretzel 타입 중 하나를 반환할 수 있다는 함수 시그니처이다.

from typing import Union
def dispense_snack(user_input: str) -> Union[HotDog, Pretzel]:
    if user_input == "Hot Dog":
        return dispense_hot_dog()
    elif user_input == "Pretzel":
        return dispense_pretzel()
    raise RuntimeError("Should never reach this code,"
                       "as an invalid input has been entered")

dispense_snackdispense_hot_dogdispense_pretzel을 통해 HotDog 또는 Pretzel 둘 중 하나를 반환할 수 있다.

사실 OptionalUnion의 특별한 케이스일 뿐인데, 가령 Optional[int]Union[int, None]과 같다.

다음의 예제를 보도록 하자.

from typing import Optional
def place_order() -> Optional[HotDog]:
    order = get_order()
    result = dispense_snack(order.name)
    if result is None
        print_error_code("An error occurred" + result)
        return None
    # Return our HotDog
    return result

place_order함수에서는 dispense_snack의 반환값을 result로 받아서 반환한다. 문제는 place_order는 반환 타입이 Optional[HotDog]이고 dispense_snackUnion[HotDog, Pretzel]이라서 호환이 안된다는 것이다.

이러한 부분은 typechecker를 통해서 걸러낼 수 있다.

error: Incompatible return value type (got "Union[HotDog, Pretzel]",
                                           expected "Optional[HotDog]")

실제 반환 타입과 함수 시그니처의 반환 타입이 다르다는 것을 쉽게 알 수 있다. 이는 반환값의 타입으로 새로운 타입이 들어오게되거나 수정되면, 엄격하게 사용자 단에서도 처리를 변경해야한다는 것이다. 이는 코드를 더욱 robust하게 만들고 버그를 줄여준다.

추가적으로 Union을 사용하는 중요한 이점이 있는데, 이를 설명하기 위해서는 type theory에 대해서 알아야 한다. type theory는 type system을 중심으로 한 수학의 한 분야이다.

3. Product and Sum types

UnionRepresentable state space를 제한할 수 있어 효율적이다. Representable state space는 object가 얻을 수 있는 가능한 모든 조합들의 집합을 말한다.

dataclass를 사용해보도록 하자.

from dataclasses import dataclass
# If you aren't familiar with data classes, you'll learn more in chapter 10
# but for now, treat this as four fields grouped together and what types they are
@dataclass
class Snack:
    name: str
    condiments: set[str]
    error_code: int
    disposed_of: bool


Snack("Hotdog", {"Mustard", "Ketchup"}, 5, False)

Snack은 몇 개의 Representable state space가 가능할까? 계산을 위해 몇 가지 가정을 해보도록 하자.
1. name은 3가지만 가능하다.
2. condiments는 4가지만 가능하다.
3. error_code는 6가지만 가능하다. 단, error가 없으면 0이고 error가 있다면 1~5의 값이다.
4. disposed_of는 true, false만 가능하다. 단, error가 있을 때는 true이다.

총 가능한 경우의 수는 3 4 6 * 2로 144가지이다. 따라서 Representable state space가 144개이다. 이를 Product type이라고 한다. Representable state space의 갯수가 product(곱 연산)에 의해 결정된다는 의미이다.

그런데 과연 144가지가 모두 필요한 state일까?? 다음의 경우를 보도록 하자.

def serve(snack):
    # if something went wrong, return early
    if snack.disposed_of:
        return
    # ...

disposed_of를 확인하는데, true이면 error가 있다고 판단하는 코드이다. 그런데 만약, 사용자가 잘못 설정하여 disposed_oftrue로 했지만 error가 없다고 하자. 즉, error_code는 0인 경우이다. 물론 이런 경우는 거의 없겠지만 Representable state space에는 분명히 존재하는 state중의 하나이다.

이는 너무 많은 state가 하나의 dataclasss안에 존재하기 때문이다. 즉, Snack은 가능하지 않은 state까지 포함하여 너무 많은 state를 가지고 있다는 것이다.

이를 해결하기 위해서 다음과 같이 코드를 만들어보도록 하자.

from dataclasses import dataclass
from typing import Union
@dataclass
class Error:
    error_code: int
    disposed_of: bool

@dataclass
class Snack:
    name: str
    condiments: set[str]

snack: Union[Snack, Error] = Snack("Hotdog", {"Mustard", "Ketchup"})

snack = Error(5, True)

기존의 SnackSnackError로 나누고 Union으로 묶어낸 것이다. 별반 다를바 없어보이지만 큰 차이가 있는데, state개수가 크게 달라진다.

Snack에서 가능한 state의 개수는 3 * 4 = 12개 이다.

Error에서 가능한 state의 개수는 6 * 2이지만 Error 클래스가 사용된다는 것 자체가 이미 disposed_oftrue인 경우이고, Error는 success인 경우를 제외한 5가지만 가능하다. 따라서 Error는 총 5개의 경우의 수가 있다.

따라서 두 타입은 Union으로 묶여 있기 때문에 합연산으로 state가 가늠된다. 총 12 + 5 = 17개의 경우의 수가 있다.

이를 Sum type이라고 한다. Product type과 달리 Sum type은 state를 더하기 때문에 state의 수가 현저히 줄어든다. 이를 통해 Representable state space가 줄어들고 발생할 수 있는 state가 줄어들면 어이없는 에러도 줄어들고 통제할 수 있게 된다. 따라서, Sum type인 Union을 사용하여 state를 줄이는 것이 굉장히 중요하다.

4. Literal types

Literal type은 변수가 특정한 value들의 집합을 갖도록 제한할 수 있다. 이는 python3.8에 나온 것으로 python의 enumeration보다 더 사용하기 편하고 쉽다는 장점이 있다.

Snack class을 Literal을 값들을 적용시키도록 수정해보자.

from typing import Literal
@dataclass
class Error:
    error_code: Literal[1,2,3,4,5]
    disposed_of: bool

@dataclass
class Snack:
    name: Literal["Pretzel", "Hot Dog", "Veggie Burger"]
    condiments: set[Literal["Mustard", "Ketchup"]]

이제 해당 Literal 이외의 값들을 가질 때 어떤 에러가 발생하는 지 알아보도록 하자.

Error(0, False)
Snack("Invalid", set())
Snack("Pretzel", {"Mustard", "Relish"})

typecheker로 확인하면 다음의 에러가 발생한다.

code_examples/chapter4/invalid/literals.py:14: error: Argument 1 to "Error" has
    incompatible type "Literal[0]";
                      expected "Union[Literal[1], Literal[2], Literal[3],
                                      Literal[4], Literal[5]]"

code_examples/chapter4/invalid/literals.py:15: error: Argument 1 to "Snack" has
    incompatible type "Literal['Invalid']";
                       expected "Union[Literal['Pretzel'], Literal['Hotdog'],
                                       Literal['Veggie Burger']]"

code_examples/chapter4/invalid/literals.py:16: error: Argument 2 to <set> has
    incompatible type "Literal['Relish']";
                       expected "Union[Literal['Mustard'], Literal['Ketchup']]"

Literal type을 잘 사용하면 코드의 동적인 변화를 규제할 수 있어 아주 좋다.

5. Annotated types

Literal은 특정 값들을 사용하도록 제한할 수 있다는 장점이 있지만, 문제가 있다. 모든 경우의 수를 Literal로 표현하지 못할 때가 있고, 규칙으로만 제한해야할 때도 있다. 가령, email의 경우 이메일 포맷에 맞는 문자열만 가능하도록 해야한다. 이러한 모든 경우의 수를 Literal로 제한할 수 없을 것이다. 이를 위해서 Annotated가 존재하는 것이다.

Annotated는 임의의 메타데이터를 type annotation에 따라 구체화할 수 있도록 해준다. 다음의 예를 확인해보도록 하자.

x: Annotated[int, ValueRange(3,5)]
y: Annotated[str, MatchesRegex('[0-9]{4}')]

문맥상으로 보면 ValueRange는 3~5의 값을 제한하고 MatchesRegex는 4자리 숫자로된 문자열로 제한하는 것처럼 보인다.

하지만 다음의 코드는 구동되지 않을 것이다. ValueRangeMatchesRegex는 따로 python에서 제공되는 것이 아니라, 직접 custom하게 구현해야하기 때문이다. 따라서, typechecker의 도움 역시도 받을 수가 없다.

6. NewType

Annotated를 사용하지 않고, 좀 더 구체적인 값을 요구할 수 있도록 타입을 지정하는 다른 방법이 있다. 그것이 바로 NewType인데 NewType은 기존의 타입을 근간으로 새로운 타입을 만들어내며, 여기서 설명을 추가해 클라이언트에게 제공해줄 수 있다. 또한, 기존 타입과는 호환이 되지 않아 기존 타입이 NewType으로 만들어진 새로운 타입을 덮어쓰는 일도 없게 만들어준다.

만약, HotDog 클래스가 있는데, HotDog 클래스가 만약 준비된 상태가 있고 준비되지 않은 상태가 있다고 하자. customer에게 제공할 때는 준비된 상태여야 하는데, 실제 코드 상에서는 HotDog준비된 상태준비되지 않은 상태가 혼재되어있을 수 밖에 없다.

class HotDog:
    # ... snip hot dog class implementation ...

def dispense_to_customer(hot_dog: HotDog):
    # note, this should only accept ready-to-serve hot dogs.
    # ...

dispense_to_customer는 입력으로 HotDog type을 받지만 준비된 상태여야만 customer에게 의미가 있다. 이를 개발자의 몫으로 돌리지않고 개발자가 직접 알 수 있게 할 수는 없을까?? 이럴 때 NewType을 사용하면 된다.

from typing import NewType

class HotDog:
    ''' Used to represent an unservable hot dog'''
    # ... snip hot dog class implementation ...

ReadyToServeHotDog = NewType("ReadyToServeHotDog", HotDog)

def dispense_to_customer(hot_dog: ReadyToServeHotDog):
    # ...

NewType는 첫번째 인자로 해당 타입에 대한 설명을 붇이고 뒤에 기존의 타입을 받는다. 그리고 이 NewType으로 만들어진 타입을 사용하면 되는 것이다. 위에서는 dispense_to_customer에서 HotDog 클래스 대신에 ReadyToServeHotDog가 쓰이게 된 것이다. 즉, ReadyToServeHotDog는 핫도그가 준비된 상태임을 나타내는 것이다.

재밌는 것은 ReadyToServeHotDogHotDog타입을 근간으로 만들어진 타입임에도 불구하고 HotDog타입이 ReadyToServeHotDog 타입으로 들어갈 수가 없다. 즉, ReadyToServeHotDogHotDog타입의 객체를 넣으면 typechecker에서 이를 발견해 에러로 알려준다.

code_examples/chapter4/invalid/newtype.py:10: error:
	Argument 1 to "dispense_to_customer"
	has incompatible type "HotDog";
	expected "ReadyToServeHotDog"

단, ReadyToServeHotDogHotDog 타입의 객체에 호환된다는 것은 알아두도록 하자. 즉, base인 HotDog는 더욱 specific한 ReadyToServeHotDog가 될 수 없지만, sub인 ReadyToServeHotDogHotDog에 대한 specific한 state이기 때문에 HotDog가 될 수 있다.

그럼 ReadyToServeHotDog`를 어떻게 사용할 수 있을까?? 다음과 같이 캐스팅해주는 함수를 하나 만들도록 한다.

def prepare_for_serving(hot_dog: HotDog) -> ReadyToServeHotDog:
    assert not hot_dog.is_plated(), "Hot dog should not already be plated"
    hot_dog.put_on_plate()
    hot_dog.add_napkins()
    return ReadyToServeHotDog(hot_dog)

HotDog 타입의 hot_dog 인스턴스를 받아서 ReadyToServeHotDog로 캐스팅하는 것이다.

한 가지 조심해야할 것은 NewType은 그저 근간이 되는 type에 대한 alias가 아니라는 것이다. alias라면 근간이 되는 type가 NewType으로 만들어진 타입 간의 호환이 되어야 하지만, NewType은 그렇지 않다.

IdOrName = Union[str, int]

다음의 IdOrNameUnion[str, int]에 대한 alias 변수이다. alias이기 때문에 IdOrName로 선언된 변수는 IdOrName또는 Union[str, int] 변수와 호환이 가능하다. 그러나 만약 IdOrNameUnion[str, int]을 통해서 NewType으로 만들어졌다면, IdOrName로 선언된 변수는 오직 IdOrName만 받을 수 있다.

alias는 그저 긴 타입의 이름을 간단히 하기위해서 사용하는 것으로 생각하면 된다. Union[dict[int, User], list[dict[str, User]]]IDORNameLookup과 같이 간단하게 만들 수 있다.

7. Final Types

Final은 python 3.8에 도입된 타입 시스템으로, 상수를 선언할 때 사용한다. Final은 이름에 걸맞게 한번 선언된 값에서 다른 값으로 변경이 불가능하다.

VENDOR_NAME: Final = "VENDOR_NAME"

def display_vendor_info():
    vendor_info = "FAKE NAME"
    VENDOR_NAME += vendor_info
    print(VENDOR_NAME)

해당 코드를 typecheker로 확인해보면 다음과 같은 에러가 발생한다.

code_examples/chapter4/invalid/final.py:3: error:
	Cannot assign to final name "VENDOR_NAME"
Found 1 error in 1 file (checked 1 source file)

Final은 굉장히 강력한데, 이를 통해서 const를 만들어낼 수 있고 const가 주는 불변성으로 프로그램을 견고하게 만들 수 있다.

0개의 댓글