티스토리 뷰

파이썬 3.9를 기준으로 파이썬 타입 어노테이션을 사용하는 방법에 대해서 알아보자. 이 문서는 공식문서를 바탕으로 만들었다.

 

NewType
Callable
Generics
Any
NoReturn
tuple
Union
Optional
type
Literal
ClassVar
Final
AnyStr
Protocol
runtime_checkable
NamedTuple
TypedDict

 

 

# 파이썬 타입 어노테이션을 적용하기 전에 확인해야 할 점

타입 어노테이션은 파이썬 3.5부터 추가가 되었다. 그리고 현제까지도 계속 관련 기능이 추가되고 있는 상황이다. 이 말은 곧 호환성을 고려해야 한다는 것이다. 특히 파이썬 3.9에서 많은 부분이 변경되었다. 그러므로 자신이 사용하는 파이썬 환경을 고려해서 어노테이션을 작성해야 한다. 

 

여기서는 파이썬 3.9를 기준으로 타입 어노테이션 사용 방법을 기록해두려고 한다. 

 

 

# 타입 어노테이션 기초 문법

타입 어노테이션을 사용하는 기초 문법을 먼저 알아야 한다. 타입 어노테이션은 식별자 뒤에 콜론(:)을 타입을 작성하면 된다.

name: str = "John"
age: int = 34

함수나 메서드의 매개변수에도 동일하게 콜론을 사용한다. 하지만 리턴 타입은 ->를 사용한다.

def count_up(count: int) -> int:
    return count + 1

컬렉션 타입은 []로 요소 타입을 감싼다.

names: list[str] = ["John", "Micheal"]
book_page_mapping: dict[str, int] = {"Intro Python": 384, "Cook book": 289}

 

 

# 파이썬 기본 타입

정수 : int

실수 : float

문자열 : str

리스트 : list

딕셔너리 : dict

세트 : set

 

 

# list, set, dict의 요소 타입

리스트와 세트의 요소의 타입을 []를 사용해서 명시할 수 있다.

names: list[str] = ['a', 'b']
ids: set[int] = {1, 5, 3}

 

딕셔너리는 [키타입, 값타입]으로 명시한다.

book_pages: [str, int] = {'python': 383, 'java': 265}

 

 

# NewType

NewType은 별개의 타입을 만들기 위해 사용한다. 

예로 들어 유저 아이디가 정수로 만들어지는 경우, 유저 아이디는 int 타입과 구별이 되지 않는다. 하지만 NewType을 사용하면 아주 쉽게 int와는 다른 타입을 만들 수 있다. 다음 코드에서는 int타입을 UserId로 새로운 타입을 만든 예제이다.

from typing import NewType

UserId = NewType('UserId', int)
some_id = UserId(123)

이렇게 만들어진 some_id는 UserId가 된다. 하지만 타입 어노테이션은 런타임시에는 주석처럼 처리되므로 int로 처리된다. 타입 어노테이션은 타입체커를 통해서 정적으로 타입 불일치를 확인할 수 있다.

 

예를 들어 다음같이 유저 아이디를 입력받는 함수가 있다고 하자. 만일 타임 어노테이션을 사용하지 않으면 user_id는 어떤 타입인지 내부 로직을 확인하지 않으면 알 수 없다. 그리고 UserId로 전달하든 정수로 전달하든 타입체커로 확인할 수도 없다.

def get_user_name(user_id) -> str:
    ...
    
user_a = get_user_name(UserId(123) # OK
user_b = get_user_name(123) # OK

 

그럼 user_id에 UserId로 명시해보자.

def get_user_name(user_id: UserId) -> str:
    ...
    
user_a = get_user_name(UserId(123) # OK
user_b = get_user_name(123) # 타입 체커 시 Error, 하지만 런타임 시 OK

수정된 get_user_name의 user_id는 이제 UserId임을 어노테이팅되었다. 이렇게 어노테이션 되어 있으니 user_id가 어떤 타입인지 쉽게 알 수 있고, 타입 체커로 잘못 전달된 타입을 쉽게 찾을 수 있다. 하지만 런타임 시에는 어노테이션은 주석처럼 처리되어서 실행에 영향을 주지 않으므로 정수 타입을 전달해도 잘 작동하게 된다.

 

## NewType 사용 시 주의사항

NewType은 새로운 타입으로 만든 것이지만, NewType을 통해서 만들어지는 새로운 객체들은 기존 타입이 된다. 예를 들어서 UserId를 더하면, 새로운 int 타입이 만들어진다. 이렇게 작동하는 이유는 실수로 UserId가 생성되는 것을 막을 수 있기 때문이다.

output = UserId(23413) + UserId(54341)

 

그리고 NewType으로 만들어진 타입을 하위 클래스를 만들 수 없다. NewType은 실제로 타입이 아니기 때문이다.

class AdminUserId(UserId): pass

 

 

# Callable

호출가능한 함수는 Callable[[Arg1Type, Arg2Type], ReturnType] 형식으로 타입을 어노테이션 할 수 있다. 매개변수가 없으면 []를 전달하고 리턴 타입이 없다면 None이 된다.

callback1: Callable[[], str]
callback2: Callable[[int], None]
callback3: Callable[[int, str], None]

만약 매개변수를 지정하지 않고 싶다면 리터럴 생략 부호(...)로 작성할 수 있다.

Callable[..., None]

 

 

# Generics

제네릭은 int처럼 고정된 타입이 아닌 동일한 타입임을 나타내기 위해서 사용한다. 예를 들어 요소가 int인 리스트의 첫 번째 요소를 추출하는 함수는 다음과 같다. 

def first(items: list[int]) -> int:
    return items[0]

 

만약 first를 str인 리스트나 float인 리스트에도 사용하고 싶다면 어떻게 해야 할까? 이때 제네릭을 사용하면 된다.

제네릭은 TypeVar를 사용해서 구현한다.

from typing import TypeVar

T = TypeVar('T')
def first(items: list[T]) -> T:
    return items[0]

 

 

# 사용자 정의 제네릭 클래스

사용자 정의 클래스를 Generic을 사용해서 제네릭 클래스를 정의할 수 있다.

from typing import TypeVar, Generic

T = TypeVar("T")


class Stack(Generic[T]):
    def __init__(self) -> None:
        self.data: list[T] = []

    def push(self, data) -> None:
        self.data.append(data)

    def pop(self) -> T:
        return self.data.pop()

 

제네릭 클래스는 타입 어노테이션으로 제네릭 타입을 []로 전달하면 된다. 다음은 int 타입을 가지는 Stack을 선언하는 코드이다.

stack: Stack[int] = Stack()

 

 

TypeVar는 다양한 제약을 설정할 수 있다.

T = TypeVar('T')  # 어떤 타입이든 가능
S = TypeVar('S', bound=str)  # str와 str 하위 타입만 가능
A = TypeVar('A', str, bytes)  # str, bytes 타입만 가능

 

제네릭 클래스는 여러 개의 제네릭 타입을 가질 수 있다.

class WeirdTrio(Generic[T, B, S]):
    ...

 

하지만 제네릭 타입은 고유해야 한다. 따라서 아래 코드는 잘못된 코드이다.

class Pair(Generic[T, T]):   # 무효
    ...

 

제네릭은 상속과 함께 사용할 수 있다. 하지만 제네릭은 실제로 상속이 되는 것은 아니다.

class LinkedList(Sized, Generic[T]):
    ...

상속할 때 일부 타입 변수를 수정할 수 있다. 또는 다시 제네릭으로 만들어 사용할 수도 있다.

class IntLinkedList(LinkedList[int]):
    ...
    
class CircularLinedList(LinkedList[T]):
    ...

만약 제네릭 타입을 명시하지 않고 상속하게 되면 이는 Any 타입으로 가정한다.

class DualLinkedList(LinkedList)
    ...

 

제네릭은 별칭으로도 사용할 수 있다.

from collections.abc import Iterable
from typing import TypeVar, Union
S = TypeVar('S')
Response = Union[Iterable[S], int]

# 리턴 타입은 Union[Iterable[str], int]와 같다.
def response(query: str) -> Response[str]:
    ...
T = TypeVar('T', int, float, complex)
Vec = Iterable[tuple[T, T]]

def inproduct(v: Vec[T]) -> T: # Iterable[tuple[T, T]]와 같다.
    return sum(x*y for x, y in v)

 

 

# Any 타입

타입 체커는 Any를 모든 타입으로 간주한다.

from typing import Any

a: Any = None
a = []          # OK
a = 2           # OK

 

그리고 타입 체커는 Any를 모든 타입으로 간주하기 때문에 다음과 같이 특정 타입에도 넣어도 에러가 발생하지 않는다.

s: str = ''
s = a           # OK

 

또한 Any 타입은 필드나 메서드도 포함되어 있을 거라고 간주한다. 만일 foo() 함수에 bar 메서드가 없는 객체를 전달해도 타입 체커는 이를 확인해주지 않는다. 그러나 코드를 실행하면 bar 메서드가 없는 객체가 실행되면서 에러가 발생하고 종료된다. 

def foo(item: Any):
    item.bar()

 

Any와 object를 비교해 보자. Any와 마찬가지로 모든 타입은 object의 하위 타입이다. 그러나 Any는 모든 타입일 뿐이다. 

def hash_a(item: object) -> int:
    # Fail, object 타입은 magic 메서드가 없다.
    item.magic()
    ...

def hash_b(item: Any) -> int:
    # OK, Any는 모든 타입이 될 수 있으니 magic 메서드가 있을 거라고 간주한다.
    item.magic()
    ...

 

하지만 다음 코드는 타입 체커가 에러를 발생시키지 않는다. 파이썬의 모든 객체는 object의 하위 클래스이고, Any도 되기 때문이다.

# OK, 모든 객체는 object의 하위 클래스이다.
hash_a(42)
hash_a("foo")

# OK, Any는 모든 타입이다.
hash_b(42)
hash_b("foo")

 

 

# 이름 하위 타입과 구조적 하위 타입

처음 PEP484에서 파이썬 정적 타입 시스템을 이름 하위 타입을 사용하는 것으로 정의했다. 이것은 클래스 A가 B의 하위 클래스인 경우에만 클래스 A를 클래스 B의 하위로 허용됨을 의미한다.

 

예로 들어, 다음 코드에서 Bucket은 Sized와 Iterable의 하위 클래스로 만든 코드이다.

from collections.abc import Sized, Iterable, Iterator

class Bucket(Sized, Iterable[int]):
    ...
    def __len__(self) -> int: ...
    def __iter__(self) -> Iterator[int]: ...

 

하지만 PEP544는 사용자가 클래스 정의에 명시적인 클래스 상속 없이, 클래스 내부에 기능을 제공함으로써 문제를 해결할 수 있게 했다. Bucket은 정적 타입 체커에 의해 Sized와 Iterable[int]의 하위 타입으로 암시적으로 간주되도록 한다. 이를 구조적 하위 타입(또는 정적 덕 타입)이라고 한다.

from collections.abc import Iterator, Iterable

class Bucket:  # Note: 클래스 상속이 없다.
    ...
    def __len__(self) -> int: ...
    def __iter__(self) -> Iterator[int]: ...

def collect(items: Iterable[int]) -> int: ...
result = collect(Bucket())  # OK, 타입 체커 패스

 

 

# NoReturn

함수가 절대 리턴을 하지 않음을 나타낸다.

from typing import NoReturn

def stop() -> NoReturn:
    raise RuntimeError('no way')

 

 

# tuple

튜플 타입은 tuple[int, str]처럼 나타낸다. 만일 빈 튜플임을 나타내려면 tuple[()]로 작성한다.

user_info: tuple[str, int] = ("John", 32)
empty_tuple: tuple[()] = ()

가변 길이 튜플을 지정하려면 리터럴 생략자(...)를 사용해서 tuple[int, ...] 처럼 사용한다. 가변 길이 튜플은 처음 작성한 타입과 동일한 타입만 가변으로 가질 수 있다. 만일 tuple[int, ...]에 int가 아닌 값을 가지면 타입 체커는 에러를 발생시킨다.

variable_length_tuple: tuple[int, ...] = (1, 2, 3, 4) # OK
variable_length_tuple = (1, 2, '3') # Error

 

 

# Union

Union[X, Y]는 X 타입이거나 Y 타입을 의미한다. 

number: Union[str, int] = 10  # OK
number = '10'  # OK

 

 

# Optional

Optional은 값을 가지거나 값이 없음을 뜻하는 None을 나타내는 어노테이션이다. 

user_count: Optional[int] = None
user_count = 1

Optional[X]는 Union[X, None]과 동일하다.

 

 

# type

type은 타입 자체의 타입을 나타낸다. 

a = 3         # a는 int 타입이다.
b = int       # b는 int 타입을 뜻하는 type[int]이다.
c = type(a)   # c도 type[int]이다.

그래서 type은 객체가 아닌 클래스를 받기 위해서 사용되는 타입이다.

class User: ...
class BasicUser(User): ...
class ProUser(User): ...
class TeamUser(User): ...

# User와 User 하위 타입인 BasicUser, ProUser, TeamUser를 전달할 수 있다.
def make_new_user(user_class: Type[User]) -> User:
    # ...
    return user_class()

 

 

# Literal

Literal은 특정 값들로 제한시키는 역할을 한다. 다음 코드에서 mode가 'r', 'rb', 'w', 'wb' 중 하나의 값만 가지게 한다.

mode: Literal['r', 'rb', 'w', 'wb'] = 'r'

타입 별칭을 사용해서 타입을 재사용할 수 있게 만들 수도 있다.

MODE = Literal['r', 'rb', 'w', 'wb']
def open_helper(file: str, mode: MODE) -> str:
    ...

open_helper('/some/path', 'r')  # OK
open_helper('/other/path', 'typo')  # Error

 

 

# ClassVar

ClassVar는 클래스 변수를 표시하는 특수 타입 구문이다. 

class Starship:
    stats: ClassVar[dict[str, int]] = {} # class variable
    damage: int = 10                     # instance variable

 

 

 

 

 

파이썬에서 클래스 레벨에서 선언된 변수는 클래스 변수로도 사용이 되고, 인스턴스 변수로도 사용이 된다. 그래서 의도치 않게 동작되는 경우가 발생하게 된다. 이를 방지하기 위해서 ClassVar가 사용된다. ClassVar로 어노테이션 된 변수는 오직 클래스 변수로만 사용한다고 어노테이팅하는 것이다. 

 

ClassVar로 어노테이팅된 클래스 변수는 오직 클래스에서 수정할 수 있다.

enterprise_d = Starship(3000)
enterprise_d.stats = {} # Error, 클래스 변수를 인스턴스에서 수정이 안 된다.
Starship.stats = {}     # OK, 클래스 변수는 클래스에서 수정할 수 있다.

 

 

# Final

Final은 상수로 만드는 어노테이션이다. 동일한 이름으로 재할당하거나 재정의할 수 없게 한다. 이는 하위 클래스에도 동일하게 적용된다.

MAX_SIZE: Final = 9000
MAX_SIZE += 1  # Error

class Connection:
    TIMEOUT: Final[int] = 10

class FastConnector(Connection):
    TIMEOUT = 1  # Error

 

Final을 사용할 때는 조심해야 할 점이 있다. 객체의 내부 메서드로 상태를 변경하는 것은 막지 않는다는 점이다. 예로 들어 리스트를 Final로 어노테이션해도 append()로 값을 변경해도 타입 체커는 이를 잡아주지 않는다. 하지만 대입 연산자(=)로 재할당하면 에러로 간주한다.

a: Final = [1, 2]
a.append(3) # OK
a = [2, 3]  # Error

 

 

# Annotated

 

 

# Generic

 

 

#TypeVar

 

 

# AnyStr

AnyStr은 AnyStr = TypeVar('AnyStr', str, bytes)로 정의된 제한된 타입 변수이다. 이것은 다른 종류의 문자열이 섞이지 않고, str 또는 bytes로 동일된 타입이 되게 한다.

def concat(a: AnyStr, b: AnyStr) -> AnyStr:
    return a + b

concat(u"foo", u"bar")  # Ok, output has type 'unicode'
concat(b"foo", b"bar")  # Ok, output has type 'bytes'
concat(u"foo", b"bar")  # Error, cannot mix unicode and bytes

 

 

# Protocol

프로토콜은 구조적 하위 타입을 만들기 위해서 사용한다. 구조적 하위 타입은 꼭 상속을 하지 않아도 내부 구조가 같으면 하위 타입처럼 작동한다. 프로토콜은 타입 체커를 위해서 도입한 어노테이션이라고 볼 수 있다. 

 

Proto 프로토콜 클래스에는 meth() 메서드가 정의되어 있다.

class Proto(Protocol):
    def meth(self) -> int:
        ...

그리고 다른 클래스에도 meth() 메서드가 정의되어 있으면 이는 Proto의 구조적 하위 타입이 된다. 그래서 아래 C클래스는 Proto를 상속하지 않고도 구조적 하위 타입이 되고, func() 함수에 C()를 전달하여도 타입 체커는 C를 Proto의 하위 타입으로 인정하고 에러가 발생하지 않는다.

class C:
    def meth(self) -> int:
        return 0

def func(x: Proto) -> int:
    return x.meth()

func(C())  # Passes static type check

자세한 내용은 PEP 544를 참조하십시오. runtime_checkable()로 장식된 프로토콜 클래스는 형식 서명을 무시하고 주어진 속성의 존재만 확인하는 단순한 런타임 프로토콜 역할을 한다.

프로토콜 클래스는 다음과 같이 일반적일 수 있습니다:

class GenProto(Protocol[T]):
    def meth(self) -> T:
        ...

 

## runtime_checkable

runttime_checkable은 프로토콜 클래스를 런타임 프로토콜로 표시한다. 프로토콜 클래스가 아닌데, runtime_checkable을 적용하면 TypeError가 발생한다. 

 

runtime_checkable은 타입 시그니처(매개변수, 리턴타입)를 비교하지 않고, 필수 메서드의 존재만 확인한다. 그래서 런타임 프로토콜은 isinstance()와 issubclass()를 사용할 수 있다. 이것은 간단하게 구조 검사를 가능하게 한다.

@runtime_checkable
class Closable(Protocol):
    def close(self): ...

assert isinstance(open('/some/file'), Closable)

 

 

# NamedTuple

NamedType은 collections.namedtuple()의 타입 버전이다. 다음은 NamedTuple로 collections.namedtuple을 만드는 방법이 된다.

class Employee(NamedTuple):
    name: str
    id: int

위의 Employee는 collections.namedtuple로 만들면 다음과 같다.

Employee = collections.namedtuple('Employee', ['name', 'id'])

 

NamedTuple의 필드에 기본 값을 줄 수도 있다. 기본값이 있는 필드는 기본값이 없는 필드 뒤에 있어야 한다.

class Employee(NamedTuple):
    name: str
    id: int = 3

employee = Employee('Guido')
assert employee.id == 3

NamedTuple로 만들어진 클래스는 __annotations__라는 추가 속성을 가진다. 이것은 필드 이름을 필드 타입에 매핑하는 딕셔너리이다. 필드 이름은 _fileds 속성에 있고, 기본값은 _filed_defaults 속성에 있다. 이 둘은 모두 namedtuple() API의 일부이다.

>>> Employee._fields
('name', 'id')
>>> Employee._field_defaults
{'id': 3}

 

NamedTuple 하위 클래스는 독스트링과 메서드를 가질 수 있다.

class Employee(NamedTuple):
    """Represents an employee."""
    name: str
    id: int = 3

    def __repr__(self) -> str:
        return f'<Employee {self.name}, id={self.id}>'

 

 

# TypedDict

TypedDict는 딕셔너리에 타입 힌트를 추가하는 특수 구조이다. 하지만 런타임 시에는 일반 딕셔너리이다.

 

TypedDict는 모든 인스턴스에 특정 키 집합이 있을 것으로 예상되는 사전 타입을 선언한다. 여기서 각 키는 일관된 타입의 값과 연결된다. 이 연결은 런타임에는 확인되지 않고, 타입 체커에 의해서만 적용된다.

class Point2D(TypedDict):
    x: int
    y: int
    label: str

a: Point2D = {'x': 1, 'y': 2, 'label': 'good'}  # OK
b: Point2D = {'z': 3, 'label': 'bad'}           # Fails type check

 

이렇게 만들어진 Point2D는 런타임시에는 Dict이기 때문에 아래는 참이 된다. 타입 체커도 패스된다.

assert Point2D(x=1, y=2, label='first') == dict(x=1, y=2, label='first')

 

기본적으로 변수에는 TypedDict의 모든 키와 동일하게 가지고 있어야 한다. 모든 키를 가지고 있지 않으면 타입 체커는 에러를 발생시킨다. 만일 변수가 모든 키를 가지지 않아도 되게 하려면 total=False를 추가하면 된다. 하지만 TypedDict에 없는 키를 가지면 이는 에러를 발생시킨다.

class Point2D(TypedDict, total=False):
    x: int
    y: int


a: Point2D = {"x": 10}  # OK
a = {"x": 10, "y": 20}  # OK
a = {"x": 10, "y": 20, "z": 30}  # Error

 

TypedDict는 상속도 지원한다.

class Point3D(Point2D):
    z: int

Point3D는 x, y, z의 필드를 가진다. 이는 다음과 동일하다.

class Point3D(TypedDict):
    x: int
    y: int
    z: int

 

TypedDict는 TypedDict가 아닌 클래스와는 상속할 수 없다. 제네릭도 같이 사용할 수는 없다.

class X(TypedDict):
    x: int

class Y(TypedDict):
    y: int

class Z(object): pass  # A non-TypedDict class

class XY(X, Y): pass  # OK

class XZ(X, Z): pass  # raises TypeError

T = TypeVar('T')
class XT(X, Generic[T]): pass  # raises TypeError
댓글
공지사항