collection type은 single value 데이터를 저장하는 collection으로 python의 list
, dict
, set
이 있다. string
역시도 character의 collection이다. 이러한 collection들은 제각기 다른 동작과 원리로 이루어져 있어, 다르게 사용해야하는데 문제는 사용자 입장에서는 이를 하나하나 디버깅하고 어떤 데이터가 내부에 있는 지 알기 쉽지 않다는 것이다. 따라서 type annotation을 사용하여 어떤 collection type인지 알기 쉽도록 하자.
collection에 대한 type annotating을 사용하면 어떤 collection type이고 그 안의 element는 어떤 type인지 쉽게 알 수 있다.
AuthorToCountMapping = dict[str, int]
def create_author_count_mapping(
cookbooks: list[Cookbook]
) -> AuthorToCountMapping:
counter = defaultdict(lambda: 0)
for book in cookbooks:
counter[book.author] += 1
return counter
create_author_count_mapping
는 cookbooks
라는 list[Cookbook]
을 매개변수로 받는다. 이를 통해 알 수 있는 것은 cookbooks
는 list
이고 내부 element로 Cookbook
이 들어간다는 것이다. 또한, 반환값의 타입으로 AuthorToCountMapping
을 반환하는데, AuthorToCountMapping
는 dict[str, int]
의 alias이다. 이를 통해서 반환값이 dict
이고 key
는 str
이며, value
는 int
라는 사실을 알 수 있다.
AuthorToCountMapping
alias를 사용한 이유는 dict[str, int]
자체 타입으로는 이 collection이 어떤 맥락인지 알 수 없기 때문이다. AuthorToCountMapping
alias이름으로 유추할 수 있듯이 해당 collection이 author가 만든 책 수를 제공한다는 것을 알 수 있다.
python의 collection이 가진 특징 중 하나는 다른 언어들과는 달리 다른 타입의 element를 같은 collection에서 관리할 수 있다는 것이다. 이렇게 서로 다른 타입들이 같은 collection안에 있는 것을 Heterogeneous collection이라고 하며, 같은 타입들로만 이루어진 일반적인 collection을 Homogeneous collection이라고 한다.
문제는 사용자가 해당 collection이 Heterogeneous collection인지 Homogeneous collection인지 확신할 수가 없다는 것이다. 이를 알기위해서는 코드를 하나하나 따라가봐야하며, 특별한 경우에 Heterogeneous collection이면 각 타입마다의 특별한 처리를 해주어야 한다.
def adjust_recipe(recipe, servings):
"""
Take a meal recipe and change the number of servings
:param recipe: A list, where the first element is the number of servings,
and the remainder of elements follow the (name, amount, unit)
format, such as ("flour", 1.5, "cup")
:param servings: the number of servings
:return list: a new list of ingredients, where the first element is the
number of servings
"""
new_recipe = [servings]
old_servings = recipe[0]
factor = servings / old_servings
recipe.pop(0)
while recipe:
ingredient, amount, unit = recipe.pop(0)
# please only use numbers that will be easily measurable
new_recipe.append((ingredient, amount * factor, unit))
return new_recipe
다음의 코드는 최악이다. 입력값으로 받는 recipe
나 반환값으로 내보내는 new_recipe
나 같은 형태를 띄는데, 둘 다 list
이고 첫번째 값은 int
타입의 servings
수를 받으며 두번째 값으로 tuple
인 (str, int, str)
을 받는다. 가령 [2, ("flour", 1.5, "cup")]
이런 모습을 띄는 것이다.
이를 알기위해서는 client가 함수를 모두 확인해보거나, doc string을 확인해야하는데 안타깝게도 doc string은 어떠한 check도 해주지 않는다. 또한 doc string은 전혀 신뢰할 수가 없다. 즉, 코드와 완전히 동일하지 않을 수 있다는 것이다.
다음의 Heterogeneous collection문제는 list
안에 두 가지 타입이 존재한다는 것이다. 하나는 int
하나는 tuple
이다. 따라서, list
안의 element에 두가지 타입이 공존한다는 것을 알려주기 위해서 Union
을 사용할 수 있다.
Ingredient = tuple[str, int, str] # (name, quantity, units)
Recipe = list[Union[int, Ingredient]] # the list can be servings or ingredients
def adjust_recipe(recipe: Recipe, servings) -> Recipe:
# ...
다음의 코드는 Recipe가
list이며 element로
int혹은
Ingredient로 이루어진 것을 볼 수 있다.
Ingredient는
tuple로
tuple[str, int, str]` 형식을 갖는다.
이렇게 타입을 지정하고나면 client입장에서 doc string을 읽는 것보다 코드를 더 쉽게 파악할 수 있고, typechecker를 통해서 Heterogeneous collection가진 여러 type에 대한 special case를 처리할 수 있다. 즉, 각 type들이 가진 서로 다른 behavior에 대처할 수 있다는 것이며, 이를 IDE와 typechecker가 도와줄 수 있다는 것이다.
물론, 이러한 Heterogeneous collection은 굉장히 최악이다. 다만, tuple
의 경우는 예외이다. list
의 경우는 어떤 타입이 나올 지 모른다는 단점이 있지만 tuple
은 명확하게 해당 부분에는 해당 값이 나온다는 특징이 있어 Heterogeneous collection을 사용하기 좋다.
가령, 하나의 데이터가 name
과 page
의 갯수를 포함하고 싶다면 다음과 같이 할 수 있다.
Cookbook = tuple[str, int] # name page count
Heterogeneous collection을 사용하기 좋은 유일한 경우로 tuple
은 각 위치의 type이 고정되며, for문을 순회하여 서로 다른 타입에 같은 동작을 할 필요가 없기 때문에 좋다.
해당 tuple
을 접근하는 방법은 다음과 같다.
food_lab: Cookbook = ("The Food Lab", 958)
odd_bits: Cookbook = ("Odd Bits", 248)
print(food_lab[0])
>>> "The Food Lab"
print(odd_bits[1])
>>> 248
dict
역시도 tuple
과 같이 많이 Heterogeneous collection으로 많이 사용된다.
food_lab = {
"name": "The Food Lab",
"page_count": 958
}
다음의 dict
는 name
은 str
이고 page_count
는 int
인 경우이다. 둘 다 key
는 str
이지만 value
타입이 str
과 int
인 경우이다. 이 또한 다음과 같이 Union
으로 표현할 수 있다.
def print_cookbook(cookbook: dict[str, Union[str,int]])
# ...
문제는 dict
의 경우 다양한 타입을 받아야 해서 Union
에 들어가는 type들이 너무 많아지며, 이러한 일은 매우 지루하다는 것이다.
때문에 dict
에 대해서는 TypedDict
라는 것이 사용된다.
TypedDict
는 python 3.8부터 도입되었다. TypedDict
는 두 가지 조건이 만족해야 사용할 이유가 있다.
1. Heterogeneous collection으로 dict를 사용해야할 때
2. 개발자가 통제할 수 없는 타입이 올 때
1번은 이해가 되었겠지만 2번은 애매모호하다. 이는 JSON이나 YAML과 같은 형식으로 온 데이터를 파싱한다고 생각해보자. 이를 받아내는 dict가 특정 key에 상응하는 value가 어떤 타입인지 알 수가 없다. 즉 통제할 수가 없는 것이다. 따라서, 이러한 경우는 TypedDict
로 받아내야 한다.
반면에, 개발자가 통제할 수 있는 경우는 dataclass
를 사용하면 된다. 즉, 어떤 key
에 어떤 값의 타입이 오는 지 안다면 class
의 dataclass
를 사용하는 것이 더 직관적이라는 것이다.
다른 회사의 API를 사용한다고 해보자. 가령 get_nutrition_from_spoonacular
라는 API를 사용해서 영양정보를 얻고싶다고 한다면 다음과 같이 할 수 있을 것이다.
nutrition_information = get_nutrition_from_spoonacular(recipe_name)
# print grams of fat in recipe
print(nutrition_information["fat"]["value"])
문제는 get_nutrition_from_spoonacular
에서 반환하는 타입이 무엇인지 모른다는 것이다. 어떤 dict
의 형태가 올지 모르니 docs를 뒤져봐야한다. 그런데 docs가 과연 실제 코드와 일치할 지 장담할 수가 없다. 때문에 실제 코드를 돌려보고 어떻게 나오는 지도 확인해야한다.
이는 매우 번거로운 일이며, 다음의 code를 review하는 사람들에게는 청천벽력같은 소리이다. 직접 코드를 살펴보고 어떤 dict가 올지 알아서 하라는 것이기 때문이다.
이런 경우에 TypedDict
를 사용해서 문제를 해결할 수 있다. 위의 get_nutrition_from_spoonacular
에서 다음과 같은 json형식의 데이터가 반환된다고 하자.
{
"recipes_used": 1,
"calories": {
"value": 1,
"uint": 2,
"confidenceRange95Percent": {
"min": 1.2,
"max": 1.5,
},
"standardDeviation": 0.5
},
"fat": {
"value": 1,
"uint": "1",
"confidenceRange95Percent": {
"min": 1.2,
"max": 1.5,
},
"standardDeviation": 0.5
},
"protein": {
"value": 1,
"uint": "1",
"confidenceRange95Percent": {
"min": 1.2,
"max": 1.5,
},
"standardDeviation": 0.5
},
"carbs": {
"value": 1,
"uint": "1",
"confidenceRange95Percent": {
"min": 1.2,
"max": 1.5,
},
"standardDeviation": 0.5
},
}
다음의 json
형식을 TypedDict
로 받는다면 다음과 하면된다.
from typing import TypedDict
class Range(TypedDict):
min: float
max: float
class NutritionInformation(TypedDict):
value: int
unit: str
confidenceRange95Percent: Range
standardDeviation: float
class RecipeNutritionInformation(TypedDict):
recipes_used: int
calories: NutritionInformation
fat: NutritionInformation
protein: NutritionInformation
carbs: NutritionInformation
nutrition_information:RecipeNutritionInformation = \
get_nutrition_from_spoonacular(recipe_name)
이렇게 만든다면 TypedDict
가 있기 때문에 typechecker가 해당 API로 받는 json의 결과와 무엇이 다른지 확인해줄 수 있고, 잘못된 타입의 연산을 막을 수 있다.
단, 이러한 TypedDict
는 언제나 typechecker를 통해서만 가능한 것이지 runtime에서는 불가능하다.
generic도 사용할 수 있는데, 이는 어떤 타입이 들어오던 상관이 없는 새로운 collection을 만들 때 사용한다. 가령 다음과 같이 입력으로 받은 list를 reverse하게 만들어주는 함수를 보도록 하자.
def reverse(coll: list) -> list:
return coll[::-1]
list
의 element type이 비어있기 때문에 어떤 element가 들어가야하는 지 고민할 수 있다. 어떤 타입이든 상관없다는 것을 알려주기 위해서 Generic
을 사용하도록 하자. Generic
을 사용하기 위해서는 typing
의 TypeVar
을 사용해야 한다.
from typing import TypeVar
T = TypeVar('T')
def reverse(coll: list[T]) -> list[T]:
return coll[::-1]
T
type을 가진 value가 list
의 element로 들어갈 수 있다는 것이며, T
는 어떠한 type도 된다. 다만, T
가 어떤 타입인지 정해지면 그 타입만 list
의 element로 들어갈 수 있다. 가령 T
가 int
라면 str
는 list
에 못들어가고 오직 int
만이 list
만에 들어갈 수 있다.
다음 예제로, Graph
class를 만든다고 하자. Graph
에는 Node
와 Edge
두 타입이 있고, 하나의 Node
에는 여러 개의 Edge
가 연결되어 있을 수 있다고 하자. 다음과 같이 만들 수 있다.
from collections import defaultdict
from typing import Generic, TypeVar
Node = TypeVar("Node")
Edge = TypeVar("Edge")
# directed graph
class Graph(Generic[Node, Edge]):
def __init__(self):
self.edges: dict[Node, list[Edge]] = defaultdict(list)
def add_relation(self, node: Node, to: Edge):
self.edges[node].append(to)
def get_relations(self, node: Node) -> list[Edge]:
return self.edges[node]
class
에 TypeVar
로 만든 타입을 Generic
으로 넣어주면 Optional
이나 Union
처럼 class[T, Y]
이렇게 타입으로 쓸 수 있다. Node
와 Edge
를 Generic
으로 덮어주고 만들어주면 다음과 같이 사용할 수 있다.
cookbooks: Graph[Cookbook, Cookbook] = Graph()
recipes: Graph[Recipe, Recipe] = Graph()
cookbook_recipes: Graph[Cookbook, Recipe] = Graph()
recipes.add_relation(Recipe("pasta1"), Recipe("pasta2"))
cookbook_recipes.add_relation(Cookbook("The Food Lab"), Recipe("pasta1"))
Graph
를 하나의 타입처럼 쓰고 있는데, 내부에 사용되는 타입을 [T,Y]
로 지정하고 있는 것을 볼 수 있다. 여기에서는 Graph[Cookbook, Cookbook]
, Graph[Recipe, Recipe]
, Graph[Cookbook, Recipe]
을 타입으로 사용하고 있다.
이렇게 Generic
을 사용하면 좋은 점이 typechecker를 통해서 확인이 가능하다는 것이다. 가령 cookbooks
의 타입이 Graph[Cookbook, Cookbook]
인데 다른 타입이 들어가면 typechecker를 통해 에러가 발생한다.
cookbooks.add_relation(Recipe('Cheeseburger'), Recipe('Hamburger'))
typechecker를 사용하면 다음의 결과가 나온다.
code_examples/chapter5/invalid/graph.py:25:
error: Argument 1 to "add_relation" of "Graph" has
incompatible type "Recipe"; expected "Cookbook"
Generic
은 위와 같이 collection의 일반적인 type을 만들 때 자주 사용되는데, 또 다른 사용처로는, 반복되는 코드를 감소시킬 때 좋다. 다음의 예제를 보도록 하자.
def get_nutrition_info(recipe: str) -> Union[NutritionInfo, APIError]:
# ...
def get_ingredients(recipe: str) -> Union[list[Ingredient], APIError]:
#...
def get_restaurants_serving(recipe: str) -> Union[list[Restaurant], APIError]:
# ...
위의 예제에서 3개의 API는 모두 실패시에는 APIError
성공시에는 각자의 반환값을 반환한다. 앞으로 이와 관련된 새로운 API를 만들다보면 Union
이 반복되고 Union
안에 Union
이 있는 복잡한 구조들이 반복된다. 이러한 반복을 줄이기 위한 좋은 방법 중 하나가 바로 Generic
이다.
T = TypeVar("T")
APIResponse = Union[T, APIError]
def get_nutrition_info(recipe: str) -> APIResponse[NutritionInfo]:
# ...
def get_ingredients(recipe: str) -> APIResponse[list[Ingredient]]:
#...
def get_restaurants_serving(recipe: str) -> APIResponse[list[Restaurant]]:
# ...
APIResponse
라는 Union[T, APIError]
의 alias를 만들고 T
는 TypeVar
로 만든 template이다. Union[T, APIError]
의 alias는 T
가 template이기 때문에 APIResponse
는 하나의 type을 받을 수 있다. 따라서 APIResponse[NutritionInfo]
이런식으로 사용할 수 있는 것이다.
이렇게 APIResonse
를 만들어 사요하면 Union
을 계속해서 반복할 필요가 없어 코드가 단순화되는 장점이 있다.
만약 기존의 dict
에서 일부 기능만 바꾼 새로운 dict
를 만들고 싶다고 하자. 가령, arugula
와 rocket
은 문맥상 같은 의미이므로 dict
에서 arugula
의 값을 저장하고, rocket
을 호출하면 arugula
의 값을 반환하도록 하고 싶다.
>>> nutrition = NutritionalInformation()
>>> nutrition["arugula"] = get_nutrition_information("arugula")
>>> print(nutrition["rocket"]) # arugula is the same as rocket
{
"name": "arugula",
"calories_per_serving": 5,
# ... snip ...
}
이렇게 하기위해서는 dict
를 custom하게 만드는 방법이 있는데, dict
를 하나하나 처음부터 구현하는 일은 쉬운 일이 아니다. 때문에 기존의 dict
를 받아서 subclass를 만드는 방법이 좋다. 즉, dict
의 기능을 모두 상속받는데, 일부는 override하는 방법이다.
class NutritionalInformation(dict):
def __getitem__(self, key):
try:
return super().__getitem__(key)
except KeyError:
pass
for alias in get_aliases(key):
try:
return super().__getitem__(alias)
except KeyError:
pass
raise KeyError(f"Could not find {key} or any of its aliases")
다음과 같이 만들 수 있다. NutritionalInformation
는 사용자가 만든 custome dict
로 dict
의 subclass이다. 때문에 dict
의 모든 기능을 상속받는다. 단, 우리가 원하는 것은 문맥상 동일한 key값이 오면 해당 key의 value를 반환하는 것이므로, 값을 반환하는 기능을 하는 __getitem__(self, key)
를 오버라이드하도록 한 것이다.
get_aliases(key)
를 통해서 key
의 이름에 해당하는 alias
가 있다면 해당 alias의 값을 가져와서 반환하는 것이 전부이다.
잘 동작하는 것처럼 보이겠지만, 한 가지 문제가 있다. 이는 다음과 같다.
# arugula is the same as rocket
>>> nutrition = NutritionalInformation()
>>> nutrition["arugula"] = get_nutrition_information("arugula")
>>> print(nutrition.get("rocket", "No Ingredient Found"))
"No Ingredient Found"
우리가 만든 custom dict
인 NutritionalInformation
는 __getitem__
만을 오버라이드했지, get
을 오버라이드하진 않았다. 때문에 get
을 호출 시에는 이전의 동작대로 실행된다.
이를 해결하기위해서 collections
모듈의 UserDict
타입을 사용하면 된다. UserDict
를 사용하여 subclass를 만들면 사용자가 원하는 dict
를 만들 수 있으며, 위와 같이 __getitem__
을 오버라이드했지만 get
은 반영이 안되는 사항이 사라진다. 즉, UserDict
는 정말 사용자가 dict
를 만들기 편하게 하기위해 여러가지를 설정해준 type인 것이다.
from collections import UserDict
class NutritionalInformation(UserDict):
def __getitem__(self, key):
try:
return self.data[key]
except KeyError:
pass
for alias in get_aliases(key):
try:
return self.data[alias]
except KeyError:
pass
raise KeyError(f"Could not find {key} or any of its aliases")
collections
의 UserDict
를 우리의 dict
의 부모 클래스로 상속받고 __getitem__
을 오버라이드하면 get
메서드도 이와 같이 적용된다. 재밌는 것은 super
를 쓸 필요없이 self.data
로 데이터를 저장할 수 있고 접근할 수 있다는 것이다.
# arugula is the same as rocket
>>> print(nutrition.get("rocket", "No Ingredient Found"))
{
"name": "arugula",
"calories_per_serving": 5,
# ... snip ...
}
이전에서 발생한 문제가 해결된다.
UserDict
뿐만 아니라 UserString
, UserList
collections model도 있다. 만약, collection의 구동 방식을 custom하고 싶다면 UserString
, UserList
, UserDict
를 사용하도록 하자. 단, 성능에 대한 어느정도의 compensation은 필요하다.
ABC는 Abstract base classes로 collections.abc
모듈에 있으며, 새로운 collection을 만들기 위해서 반드시 지켜야할 method들을 정리한 것으로 생각하면 된다. 재밌는 것은, ABC에서 요구하는 일부 method들을 구현하면 나머지는 자동으로 구현해준다는 것이다.
UserSet
은 따로 없기 때문에 collections.abc.Set
를 사용하여 만들어야 한다. collections.abc.Set
은 다음의 3가지 메서드를 구현해야 하는데, 이 3개만 구현하면 집합 연산(교집합, 합집합 등)과 equal연산 등이 자동으로 구현된다.
__contains__
: value
가 set안에 있는 지, 없는 지 확인한다. 이 메서드를 구현할 때 alias에 있는 value
인지도 확인한다.__iter__
: iterating을 위해 사용한다.__len__
: length를 확인한다.collections.abc.Set
을 상속받고, 이 3가지 메서드를 구현하여 user custom set collection을 만들어보도록 하자.
import collections
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)
다음과 같이 만들 수 있다. AliasdIngredients
클래스는 collections.abc.Set
을 입력으로 받고, 내부 데이터로 self.ingredients
set을 운용한다. __contains__
메서드를 통해서 해당 value
가 self.ingredients
에 있는 지 확인하고 없으면 alias
로 동일한게 있는 지 확인하도록 한다.
잘 동작하는 지 확인해보도록 하자.
def get_aliases(value: str) -> dict[str,str]:
aliases = {"rocket": "arugula"}
return aliases
ingredients = AliasedIngredients({'arugula', 'eggplant', 'pepper'})
for ingredient in ingredients:
print(ingredient) # 'arugula' 'eggplant' 'pepper'
print(len(ingredients)) #3
print('arugula' in ingredients) #True
print('rocket' in ingredients) #True
list(ingredients | AliasedIngredients({'garlic'})) # ['pepper', 'arugula', 'eggplant', 'garlic']
잘 동작하는 것을 볼 수 있다. 이 밖에도 collections.abc
는 다양한 것들이 있다. 굳이 collections.abc
를 통해 새로운 collection을 만들 필요는 없이 generic한 type check로도 사용할 수 있다. 가령, __iter__
을 구현한 타입만 매개변수로 들어갈 수 있다면 다음과 같이 할 수 있다.
def print_items(items: collections.abc.Iterable):
for item in items:
print(item)
collections.abc.Iterable
는 Iterable
ABC로 __iter__
메서드를 구현한 타입만 받을 가능하다.
참고로 python3.9부터 25개의 서로 다른 ABC가 제공되므로 한 번 확인해보도록 하자. https://docs.python.org/3/library/collections.abc.html#module-collections.abc