https://www.yes24.com/Product/Goods/111419833
영어로는 Robust Python인데, 한국어로 단단한(?) python으로 해석되어 출판되었다. 아무리봐도 '단단한'보다는 '견고한'이 맞는 것 같다.
파이썬에 type을 표시하는 일은 매우매우 중요하다. 누군가는 python의 type을 표시하면 python의 동적 타입 언어라는 장점이 사라진다는 데, 전혀 아니다.
type표시를 해도 동적 타입 언어라는 것은 분명하다. 만약, type표시가 불만이라면 golang을 한 번 해보고 와라. 강타입 언어의 맛을 보면, python에 타입 표시 하나하는게 그닥 어렵지 않을 것이다.
협업을 하는 데 있어서 python의 type은 매우 중요하다. 누군가가 마구마구 만들어낸 python code를 타입 힌트도 없이, 매번 상위 호출을 따라들어가 타입을 확인하는 것은 불편한 일이다.
이제 python에 type hint를 붙이고 광명을 찾아보도록 하자.
파이썬은 동적 타입 언어으로 runtime전까지는 타입을 알 수 없다. 이러한 부분은 python을 개발할 때 견고한 코드를 만들게하기 어려운 부분 중 하나이며, 디버깅을 매우 어렵게 만든다. 따라서, python으로 개발할 때는 나름의 규칙을 정해야하는데, python에서 type에 대한 규칙이 바로 type annotation
인 것이다.
python의 특정 변수에 대해서 어떤 type인지 알려줄 수 있는 기능이 python3.6부터 추가되었다. str
, int,
float
, list
, tuple
, dict
등의 타입을 변수 옆에 적어주면 된다.
a: int = 10
b: str = "hello"
c: list = []
variable: type
으로 적는 것이 바로 type annotation이다. 기본 type뿐만 아니라 class도 타입으로 사용이 가능하다.
다음의 코드를 확인하도록 하자.
def close_kitchen_if_past_close(point_in_time: datetime.datetime):
if point_in_time >= closing_time():
close_kitchen()
log_time_closed(point_in_time)
위 코드에서 type annotation을 사용한 부분은 datetime.datetime
이다. 이는 class로 하나의 타입이 된 것이다.
python에서의 'type annotation'은 그저 type에 대한 hint이다. 이는 해당 변수에 기대되는 값을 설명하는 부분으로, runtime에는 영향을 미치지 않아 사실상 지키지 않아도 된다. 즉, 주석과도 같지만, 단지 개발할 때 hint를 주는 것이다.
또한, python의 경우는 duck typeing을 지원하기 때문에, type이 가진 functionality가 동일하면 문제없이 호환된다. 다만, 일부러 type annotation을 어기는 일은 없도록 하자. 이는 버그를 유도하도록 하는 코드를 만들기 때문이다.
type annotation을 사용하면 다음의 장점을 얻을 수 있다.
1. 개발자는 자신의 코드를 명확히하고 디버깅하기 쉬워진다.
2. reader는 더욱 쉽게 코드를 전체적으로 파악할 수 있고, 인수인계가 쉬워진다.
3. 사용자는 코드에 필요한 type들을 알게되어, 코드를 사용할 때 호환성 문제를 최소화할 수 있다.
함수의 파라미터 뿐만 아니라, 함수의 반환값에서도 type annotation을 설정할 수 있다.
def fake_func() -> <type>:
다음과 같이 <type>
부분을 원하는 type으로 변경하면 된다.
가령, 'str'을 담은 'list'를 반환한다고 하면 다음과 같다.
def find_workers_available_for_time(open_time: datetime.datetime) -> list[str]:
이렇게 작성하면, 사용자나 코드를 분석하는 사람은 반환값이 무엇인지 알기위해 코드를 하나하나 분석할 필요가 없다.
참고로, python3.8부터는 built-in collection types를 제공하는 데, List, Dict
같은 것들을 제공한다. 이는 list
와 dict
와 같은 소문자와 bracket이 함께 나오는 타입을 사용하지 않게 하기 위함이다. 즉, list[str]
이 아니라 List[str]
을 쓰게 하고싶어서 만들어졌다.
from typing import Dict,List
AuthorToCountMapping = Dict[str, int]
def count_authors(
cookbooks: List[Cookbook]
) -> AuthorToCountMapping:
# ...
당연하게도 type annotation은 변수에도 사용할 수 있다.
workers: list[str] = find_workers_available_for_time(open_time)
numbers: list[int] = []
ratio: float = get_ratio(5,3)
number: int = 0
text: str = "useless"
values: list[float] = [1.2, 3.4, 6.0]
worker: Worker = Worker()
한 가지 조심해야할 것은 모든 곳들에 type annotation을 난무할 필요가 없다. type annotation은 그저 type hint를 위한 것이고, type annotation에 너무 집중하여 코드를 복잡하게 만들 필요없다.
가장 먼저 Autocomplete이다. python은 동적 타입 언어이기 때문에, 동작에 있어서 어떤 일이 발생할 지 예측하기가 어렵다. 이때 type hint를 제공하는 type annotation이 있다면 ide에서 자동완성을 도와주기 때문에 많은 부분에서 도움을 얻을 수 있다.
다음은 Typecheckers이다. type annotation이 제공해주는 type hint는 말 그대로 type을 지키지 않아도 되는 hint일 뿐이다. 따라서, type에 대한 엄격한 check가 없어서 다른 type을 넣어주는 버그를 만들더라도 runtime에 해당 코드가 실행되기 이전까지 버그가 발생하지 않는다. 만약, 잘못된 type을 입력 및 반환하면 runtime이전에 에러가 발생하도록 하고 싶다면 Typecheckers을 사용하면 된다.
다음의 예제를 보도록 하자.
a: int = 5
a = "string"
print(a) # "string"
a
변수의 type이 int
였다가 "string"
을 받고 str
이 되었다. 이러한 부분들은 코드를 디버깅하고 개발하는 데 있어 큰 걸림돌이 된다. test단계에서 이러한 부분들을 검출해주고 type에 대한 규약을 어기지 않았는 지 체크하는 tool이 바로 typechecker이다. 이는 static analysis tool과 과도 비슷하다.
가장 유명한 typechecker인 mypy
를 사용해보도록 하자.
pip3 install mypy
다음으로 위의 예제를 python code로 만들어보도록 하자.
a: int = 5
a = "string"
print(a)
해당 code를 mypy
로 분석해보도록 하자.
mypy invalid_type.py
invalid_type.py:2: error: Incompatible types in assignment (expression has type "str", variable has type "int") [assignment]
Found 1 error in 1 file (checked 1 source file)
에러를 잘보면 int
인 것을 str
로 바꿨다고 알려준다. 해당 부분을 수정하도록 하자.
a: int = 5
a = 10
print(a)
이제 mypy를 돌려주도록 하자.
mypy invalid_type.py
Success: no issues found in 1 source file
성공한 것을 볼 수 있다. 이처럼 mypy
와 같은 tool을 typechecker
라고 하며 사용자의 의도와는 다르게 type을 오용하는 것을 막아주고, 잠재 문제를 걸러주는 기능을 한다.
몇 가지 예시를 보도록 하자.
def read_file_and_reverse_it(filename: str) -> str:
with open(filename) as f:
# Convert bytes back into str
return f.read().encode("utf-8")[::-1]
다음의 코드는 무엇이 문제일까?? 한 번 유추해보고 mypy
로 결과를 확인해보도록 하자.
mypy invalid_example1.py
invalid_example1.py:4: error: Incompatible return value type (got "bytes", expected "str") [return-value]
Found 1 error in 1 file (checked 1 source file)
반환값이 str
이 아니라 bytes
가 나왔다고 한다. 이는 encode
가 아니라 사실 decode
를 해주어야 하기 때문이다.
다음은 무엇이 문제일지 생각해보자.
# takes a list and adds the doubled values
# to the end
# [1,2,3] => [1,2,3,2,4,6]
def add_doubled_values(my_list: list[int]):
my_list.update([x*2 for x in my_list])
add_doubled_values([1,2,3])
mypy
를 구동하면 다음과 같다.
mypy invalid_example2.py
invalid_example2.py:2: error: "list[int]" has no attribute "update" [attr-defined]
Found 1 error in 1 file (checked 1 source file)
update
가 없다고 나오는 데 update
는 사실 list
의 method가 아니라 set
의 method이다. 즉, list
에게 없는 attributes를 호출했기 때문에 mypy
가 error로 표현한 것이다.
다음은 무엇이 문제인지 생각해보도록 하자.
# The restaurant is named differently
# in different parts of the world
ITALY_CITIES = "italy"
GERMANY_CITIES = "germany"
US_CITIES = "us"
def get_restaurant_name(city: str) -> str:
if city in ITALY_CITIES:
return "Trattoria Viafore"
if city in GERMANY_CITIES:
return "Pat's Kantine"
if city in US_CITIES:
return "Pat's Place"
return None
if get_restaurant_name('Boston'):
print("Location Found")
mypy
의 결과는 다음과 같다.
mypy invalid_example3.py
invalid_example3.py:14: error: Incompatible return value type (got "None", expected "str") [return-value]
Found 1 error in 1 file (checked 1 source file)
이 부분은 사실 조금 미묘한 문제이다. 문자열을 반환하는데 매칭되는 것이 없다면 None
을 반환하는 것이 문제가 되진 않기 때문이다. 즉, if
문으로 None
일 때를 처리할 수 있기 때문이다.
그러나 이런 경우는 None
과 str
둘 중 하나가 반환된다고 표현해야하는 것이 맞다. 만약, 사용자가 get_restaurant_name
함수의 시그니처만 보고 str
이 반환된다고 알고있으면 str
에 대한 method들을 호출하려고 할 수 있다. 이 때 None
인지 아닌지에 대한 검사를 생략할 수 있으며, 이런 경우 runtime이 에러가 발생하게 되고 디버깅하기 매우 어려운 상황을 맞이할 수 있다.