기본적인 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부터 알아보도록 하자.
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_string
은 Optional
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
일 때를 따로 처리해주면 해당 에러가 사라지게 될 것이다.
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_snack
는 dispense_hot_dog
와 dispense_pretzel
을 통해 HotDog
또는 Pretzel
둘 중 하나를 반환할 수 있다.
사실 Optional
은 Union
의 특별한 케이스일 뿐인데, 가령 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_snack
은 Union[HotDog, Pretzel]
이라서 호환이 안된다는 것이다.
이러한 부분은 typechecker를 통해서 걸러낼 수 있다.
error: Incompatible return value type (got "Union[HotDog, Pretzel]",
expected "Optional[HotDog]")
실제 반환 타입과 함수 시그니처의 반환 타입이 다르다는 것을 쉽게 알 수 있다. 이는 반환값의 타입으로 새로운 타입이 들어오게되거나 수정되면, 엄격하게 사용자 단에서도 처리를 변경해야한다는 것이다. 이는 코드를 더욱 robust하게 만들고 버그를 줄여준다.
추가적으로 Union
을 사용하는 중요한 이점이 있는데, 이를 설명하기 위해서는 type theory에 대해서 알아야 한다. type theory는 type system을 중심으로 한 수학의 한 분야이다.
Union
은 Representable 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_of
를 true
로 했지만 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)
기존의 Snack
을 Snack
과 Error
로 나누고 Union
으로 묶어낸 것이다. 별반 다를바 없어보이지만 큰 차이가 있는데, state개수가 크게 달라진다.
Snack
에서 가능한 state의 개수는 3 * 4 = 12개 이다.
Error
에서 가능한 state의 개수는 6 * 2
이지만 Error
클래스가 사용된다는 것 자체가 이미 disposed_of
가 true
인 경우이고, 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를 줄이는 것이 굉장히 중요하다.
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을 잘 사용하면 코드의 동적인 변화를 규제할 수 있어 아주 좋다.
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자리 숫자로된 문자열로 제한하는 것처럼 보인다.
하지만 다음의 코드는 구동되지 않을 것이다. ValueRange
와 MatchesRegex
는 따로 python에서 제공되는 것이 아니라, 직접 custom하게 구현해야하기 때문이다. 따라서, typechecker의 도움 역시도 받을 수가 없다.
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
는 핫도그가 준비된 상태임을 나타내는 것이다.
재밌는 것은 ReadyToServeHotDog
는 HotDog
타입을 근간으로 만들어진 타입임에도 불구하고 HotDog
타입이 ReadyToServeHotDog
타입으로 들어갈 수가 없다. 즉, ReadyToServeHotDog
에 HotDog
타입의 객체를 넣으면 typechecker에서 이를 발견해 에러로 알려준다.
code_examples/chapter4/invalid/newtype.py:10: error:
Argument 1 to "dispense_to_customer"
has incompatible type "HotDog";
expected "ReadyToServeHotDog"
단, ReadyToServeHotDog
는 HotDog
타입의 객체에 호환된다는 것은 알아두도록 하자. 즉, base인 HotDog
는 더욱 specific한 ReadyToServeHotDog
가 될 수 없지만, sub인 ReadyToServeHotDog
는 HotDog
에 대한 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]
다음의 IdOrName
는 Union[str, int]
에 대한 alias 변수이다. alias이기 때문에 IdOrName
로 선언된 변수는 IdOrName
또는 Union[str, int]
변수와 호환이 가능하다. 그러나 만약 IdOrName
이 Union[str, int]
을 통해서 NewType
으로 만들어졌다면, IdOrName
로 선언된 변수는 오직 IdOrName
만 받을 수 있다.
alias
는 그저 긴 타입의 이름을 간단히 하기위해서 사용하는 것으로 생각하면 된다. Union[dict[int, User], list[dict[str, User]]]
을 IDORNameLookup
과 같이 간단하게 만들 수 있다.
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
가 주는 불변성으로 프로그램을 견고하게 만들 수 있다.