Python에서 여러 반환 유형에 대해 유형 힌트를 사용하는 방법 II

2024. 2. 7. 19:33python/intermediate

팩토리 함수의 반환 값에 주석 달기

팩토리 함수는 처음부터 새로운 함수를 생성하는 고차 함수입니다. 팩토리의 매개변수는 이 새로운 함수의 동작을 결정합니다. 특히, 콜러블을 취하고 반환하는 함수를 Python에서는 데코레이터 라고 합니다.

이전 예제를 계속해서 코드에서 다른 함수의 실행 시간을 측정하기 위해 데코레이터를 작성하려면 어떻게 해야 할까요? parse_email()함수가 완료되는 데 걸리는 시간을 측정하는 방법은 다음과 같습니다.

In [19]:
import functools
import time
from collections.abc import Callable
from typing import ParamSpec, TypeVar

P = ParamSpec("P")
T = TypeVar("T")
def timeit(function: Callable[P, T]) -> Callable[P, T]:
    @functools.wraps(function)
    def wrapper(*args: P.args, **kwargs: P.kwargs):
        start = time.perf_counter()
        result = function(*args, **kwargs)
        end = time.perf_counter()
        print(f"{function.__name__}() finished in {end - start:.10f}s")
        return result
    return wrapper

timeit() 데코레이터는 임의의 입력 및 출력이 있는 콜러블을 인수로 사용하고 동일한 입력 및 출력이 있는 콜러블을 반환합니다. ParamSpec 주석은 Callable의 첫 번째 요소에 있는 임의의 입력을 나타내고, TypeVar는 두 번째 요소에 있는 임의의 출력을 나타냅니다.

timeit() 데코레이터는 타이머 함수를 사용하여 인수로 제공된 콜러블을 실행하는 데 걸리는 시간을 측정하는 내부 함수, 즉, wrapper()를 정의 합니다. 이 내부 함수는 현재 시간을 start 변수에 저장하고, 반환 값을 캡처하는 동안 장식된 함수를 실행하고, 새 시간을 end 변수에 저장합니다. 그런 다음 장식된 함수의 값을 반환하기 전에 계산된 기간을 인쇄합니다.

timeit()를 정의한 후에 는 공장 함수인 것처럼 수동으로 호출하는 대신 기호(@)를 구문 설탕으로 사용하여 함수를 장식할 수 있습니다. 예를 들어, parse_email() 주위에 있는 @timeit 사용하여 자체 실행 시간을 담당하는 추가 동작이 포함된 새 함수를 만들 수 있습니다.

In [20]:
@timeit
def parse_email(email_address: str) -> tuple[str, str] | None:
    if "@" in email_address:
        username, domain = email_address.split("@")
        return username, domain
    return None

소스 코드를 수정하지 않고 선언적 스타일 로 함수에 새로운 기능을 추가했습니다 . 이는 우아하지만 Python의 Zen 에 다소 어긋납니다 . 데코레이터가 코드를 덜 명확하게 만든다고 주장할 수도 있습니다. 동시에 코드를 더 단순하게 만들어 가독성을 향상시킬 수 있습니다.

장식된 parse_email() 함수를 호출하면 예상된 값이 반환되지만 원래 함수를 실행하는 데 걸린 시간을 설명하는 메시지도 인쇄됩니다.

In [21]:
from collections.abc import Callable
import functools
import time
from typing import ParamSpec, TypeVar

username, domain = parse_email("claudia@realpython.com")
parse_email() finished in 0.0000042690s

username
  Cell In[21], line 7
    parse_email() finished in 0.0000042690s
                                         ^
SyntaxError: invalid decimal literal
In [9]:
domain
---------------------------------------------------------------------------
NameError                                 Traceback (most recent call last)
Cell In[9], line 1
----> 1 domain

NameError: name 'domain' is not defined

위 username, domain 두 군데에서 에러가 발생하였습니다.

위의 메시지에 표시된 것처럼 기능 기간은 무시할 수 있습니다. 데코레이팅된 함수를 호출한 후 반환된 튜플을username 및 domain 라는 변수에 할당하고 압축을 풉니다. 다음으로, 요청 시 한꺼번에 값을 생성하는 대신 하나씩 값을 생성하는 생성기 함수의 반환 값에 주석을 추가하는 방법을 알아봅니다.

생성기가 산출한 값에 주석 달기

때로는 특히 대규모 데이터 세트의 경우 효율성을 높이기 위해 생성기를 사용하여 모든 데이터를 메모리에 저장하는 대신 한 번에 하나씩 데이터 조각을 생성 할 수 있습니다 . Python에서 유형 힌트를 사용하여 생성기 함수 에 주석을 달 수 있습니다 . 그렇게 하는 한 가지 방법은 collections.abc 모듈의 Generator 유형을 사용하는 것입니다.

참고: Callable 유형과 마찬가지로 더 이상 사용되지 않는 typing.Generator 유형과 collections.abc.Generator를 혼동하지 마십시오.

이전 예를 계속해서 분석할 이메일 목록이 많다고 상상해 보세요. 구문 분석된 모든 결과를 메모리에 저장하고 함수가 모든 것을 한 번에 반환하도록 하는 대신 생성기를 사용하여 구문 분석된 사용자 이름과 도메인을 한 번에 하나씩 생성할 수 있습니다.

그렇게 하려면 이 정보를 생성하는 다음 생성기 함수를 작성하고 해당 Generator유형을 반환 유형에 대한 유형 힌트로 사용할 수 있습니다.

In [22]:
from collections.abc import Generator

def parse_email() -> Generator[tuple[str, str], str, str]:
    sent = yield "", ""
    while sent != "":
        if "@" in sent:
            username, domain = sent.split("@")
            sent = yield username, domain
        else:
            sent = yield "invalid email"
    return "Done"

parse_email() 생성기 함수는 인수를 결과 생성기 개체로 보내므로 인수를 취하지 않습니다. Generator 유형 힌트 에는 세 개의 매개변수가 필요하며 그 중 마지막 두 개는 선택사항입니다.

  1. 항복 유형 : 첫 번째 매개변수는 생성기가 산출하는 것입니다. 이 경우 이는 두 개의 문자열을 포함하는 튜플입니다. 하나는 사용자 이름용이고 다른 하나는 도메인용이며 둘 다 이메일 주소에서 구문 분석됩니다.
  2. Send type : 두 번째 매개변수는 생성기로 보내는 내용을 설명합니다. 이메일 주소를 생성기에 보내게 되므로 이는 문자열이기도 합니다.
  3. 반환 유형 : 세 번째 매개변수는 생성기가 값 생성을 완료했을 때 반환하는 내용을 나타냅니다. 이 경우 함수는 "Done" 문자열을 반환합니다.

생성기 함수를 사용하는 방법은 다음과 같습니다.

In [24]:
generator = parse_email()
next(generator)
Out[24]:
('', '')
In [25]:
generator.send("claudia@realpython.com")
Out[25]:
('claudia', 'realpython.com')
In [26]:
generator.send("realpython")
Out[26]:
'invalid email'
In [27]:
try:
    generator.send("")
except StopIteration as ex:
    print(ex.value)
Done

새로운 생성기 개체를 반환하는 parse_email() 생성기 함수를 호출하는 것부터 시작합니다. 그런 다음 내장 .next() 함수를 호출하여 생성기를 첫 번째 yield문 으로 진행합니다. 그런 다음 이메일 주소를 생성기로 보내 구문 분석을 시작할 수 있습니다. 빈 문자열이나 at 기호(@)가 없는 문자열을 보내면 생성기가 종료됩니다.

생성기는 반복자 (즉, 생성기 반복자) 이기도 하기 때문에 비슷한 의미를 전달하기 위해 유형 힌트 대신 해당 collections.abc.Iterator 유형을 사용할 수도 있습니다. 그러나 순수 Iterator 유형 힌트를 사용하여 보내기 및 반환 유형을 지정할 수 없기 때문에 collections.abc.Iterator는 생성기가 값만 산출하는 경우에만 작동합니다.

In [28]:
from collections.abc import Iterator

def parse_emails(emails: list[str]) -> Iterator[tuple[str, str]]:
    for email in emails:
        if "@" in email:
            username, domain = email.split("@")
            yield username, domain

이 parse_email() 함수의 특징은 문자열 목록을 가져와 for 루프를 사용하여 게으른 방식으로 반복하는 생성기 개체를 반환합니다. 생성기가 반복자보다 더 구체적이기는 하지만 후자가 여전히 광범위하게 적용 가능하고 읽기 쉽기 때문에 유효한 선택입니다.

때때로 Python 프로그래머는 구현 세부 사항을 유출하지 않고 이러한 생성기에 주석을 달기 위해 훨씬 덜 제한적이고 보다 일반적인 collections.abc.Iterable 유형을 사용합니다.

In [29]:
from collections.abc import Iterable

def parse_emails(emails: Iterable[str]) -> Iterable[tuple[str, str]]:
    for email in emails:
        if "@" in email:
            username, domain = email.split("@")
            yield username, domain

이 경우 함수의 인수와 반환 유형 모두에 해당 Iterable 유형으로 주석을 달아 함수를 더욱 다양하게 만듭니다. 이제 이전과 같은 목록 대신 반복 가능한 객체를 허용할 수 있습니다.

반대로, 함수 호출자는 루프를 반복할 수 있는 한 생성기 또는 항목 시퀀스를 반환하는지 여부를 알 필요가 없습니다. 이는 유형 힌트를 통해 설정된 호출자와의 계약을 위반하지 않고 열성적 목록 컨테이너에서 지연 생성기로 구현을 변경할 수 있기 때문에 엄청난 유연성을 추가합니다. 반환된 데이터가 생성기가 필요할 만큼 충분히 클 것으로 예상되는 경우 이 작업을 수행할 수 있습니다.

참고: 위의 예에서처럼 나중에 반환 유형을 자유롭게 변경할 수 있지만 대부분의 경우 반환 유형 주석을 최대한 구체적으로 지정하려고 노력해야 합니다.

이 예에서 본 것처럼 유형 힌트로 생성기에 주석을 달 때 몇 가지 옵션이 있습니다.

유형 별칭으로 가독성 향상

이제 유형 힌트를 사용하여 다양한 유형을 지정하는 방법을 살펴보았으므로 유지 관리에 대한 모범 사례를 고려해 볼 가치가 있습니다. 이 주제에 대한 첫 번째 개념은 유형 별칭 에 관한 것입니다 .

여러 함수에서 동일한 반환 유형 집합을 사용하는 경우 코드베이스 전체의 서로 다른 위치에서 모든 반환 유형을 별도로 유지하려고 하면 지루해질 수 있습니다. 대신 별칭 유형을 사용하는 것을 고려해 보세요 . 유형 힌트 세트를 별칭에 할당하고 코드 내 여러 함수에서 해당 별칭을 재사용할 수 있습니다.

이 작업의 주요 이점은 특정 유형 힌트 세트를 수정해야 하는 경우 단일 위치에서 수정할 수 있으며 이를 사용하는 각 함수에서 반환 유형을 리팩토링 할 필요가 없다는 것입니다 .

여러 함수에서 동일한 유형 힌트를 재사용할 의도가 없더라도 유형 별칭을 사용하면 코드의 가독성을 높일 수 있다는 점은 주목할 가치가 있습니다. PyCon US 2022 의 기조 연설에서 Łukasz Langa는 유형에 의미 있는 이름을 지정하는 것이 코드를 더 잘 이해하는 데 어떻게 도움이 될 수 있는지 설명했습니다 .

유형 힌트에 별칭 이름을 지정한 다음 이 별칭을 유형 힌트로 사용하면 됩니다. 다음은 이전과 동일한 기능에 대해 이 작업을 수행하는 방법에 대한 예입니다.

In [30]:
EmailComponents = tuple[str, str] | None

def parse_email(email_address: str) -> EmailComponents:
    if "@" in email_address:
        username, domain = email_address.split("@")
        return username, domain
    return None

여기서는 함수의 반환 값을 나타내는 유형 힌트의 별칭으로 새 EmailComponents 변수를 정의합니다. 이 값은 두 개의 문자열을 포함하는 None 혹은 튜플일 수 있습니다. 그런 다음 함수 서명에 EmailComponents 별칭을 사용합니다.

Python 버전 3.10에는 유형 별칭을 일반 변수와 더 명확하고 구별되게 만드는 TypeAlias 선언이 도입되었습니다. 사용 방법은 다음과 같습니다.

In [31]:
from typing import TypeAlias

EmailComponents: TypeAlias = tuple[str, str] | None

별칭에 주석을 달기 위한 유형 힌트로 EmailComponents을 사용하려면 먼저 typing 모듈에서 TypeAlias를 가져와야 합니다. 가져온 후에는 위에서 설명한 대로 유형 별칭에 대한 유형 힌트로 사용할 수 있습니다.

Python 3.12부터 새로운 소프트 키워드 유형을 사용하여 유형 별칭을 지정할 수 있습니다. 소프트 키워드는 컨텍스트에서 명확할 때만 키워드가 됩니다. 그렇지 않으면 다른 의미를 가질 수 있습니다. 이는 type()이 Python에 내장된 함수 중 하나이기도 함을 기억하세요. 새로운 소프트 키워드를 사용하는 방법은 다음과 같습니다.

In [32]:
type EmailComponents = tuple[str, str] | None
  Cell In[32], line 1
    type EmailComponents = tuple[str, str] | None
         ^
SyntaxError: invalid syntax

여기에서도 선언을 하지 않아 에러가 발생합니다.

Python 3.12부터 위 예제에서 수행한 것처럼 type을 사용하여 유형 별칭을 지정할 수 있습니다. 유형 별칭 이름과 유형 힌트를 지정할 수 있습니다. type 사용의 이점은 수입이 필요하지 않다는 것입니다.

참고:type 및 TypeAlias 사용에는 미묘한 차이가 있으며, 이는 서로를 즉시 대체할 수 없습니다. Python 3.12의 type과 다른 새로운 입력 기능에 대해 자세히 알아보려면 Python 3.12 미리 보기: 정적 입력 개선 사항을 확인하세요 .

설명적이고 의미 있는 이름으로 유형 힌트를 앨리어싱하는 것은 코드의 가독성과 유지 관리성을 향상시킬 수 있는 간단하면서도 우아한 방법이므로 간과하지 마십시오.

정적 유형 검사를 위한 도구 활용

동적으로 유형이 지정되는 언어 인 Python은 실제로 런타임 에 유형 힌트를 적용하지 않습니다. 즉, 함수는 원하는 반환 유형을 지정할 수 있으며 프로그램은 실제로 해당 유형의 값을 반환하거나 예외를 발생시키지 않고 계속 실행됩니다.

Python은 런타임에 유형 힌트를 적용하지 않지만 유형 확인을 위해 타사 도구를 사용할 수 있으며 그 중 일부는 플러그인을 통해 코드 편집기와 통합될 수 있습니다. 개발 또는 테스트 프로세스 중에 유형 관련 오류를 잡는 데 도움이 될 수 있습니다.

Mypy는 널리 사용되는 Python용 타사 정적 유형 검사 도구입니다. 다른 옵션으로는 pytype , Pyre 및 Pyright 가 있습니다 . 이들은 모두 해당 값에서 변수 유형을 추론하고 해당 유형 힌트를 확인하는 방식으로 작동합니다.

프로젝트에서 mypy를 사용하려면 먼저 pip을 사용하여 가상 환경에 mypy패키지를 설치하세요.

(venv) $ python -m pip install mypy

parse_email()이전 함수를 떠올려 보면 이메일 주소가 포함된 문자열을 매개변수로 사용합니다. 그 외에는 None과 사용자 이름과 도메인을 포함하는 두 문자열 중 하나인 튜플을 반환합니다. 아직 저장하지 않았다면 이 함수를 email_parser.py 이름의 파일에 저장하세요 .

In [36]:
# email_parser.py

def parse_email(email_address: str) -> tuple[str, str] | None:
    if "@" in email_address:
        username, domain = email_address.split("@")
        return username, domain
    return None

명령줄에 mypy 뒤에 Python 파일 이름을 입력하여서 유형 검사기를 통해 이 코드를 실행할 수 있습니다 .

(venv) $ mypy email_parser.py
Success: no issues found in 1 source file

이는 코드를 실행하지 않고 자동화된 정적 코드 분석을 실행합니다. Mypy는 선언된 유형 힌트에 따라 실제 값이 예상 유형을 가질지 여부를 평가하려고 합니다. 이 경우 모든 것이 올바른 것 같습니다.

하지만 코드에 실수가 있으면 어떻게 될까요? 선언된 반환 값에 parse_email()두 문자열의 튜플 대신 문자열을 나타내는 잘못된 유형 힌트가 있다고 가정해 보겠습니다.

In [37]:
# email_parser.py

def parse_email(email_address: str) -> str | None:
    if "@" in email_address:
        username, domain = email_address.split("@")
        return username, domain
    return None

위의 수정된 parse_email()함수에는 유형 힌트와 실제로 반환되는 값 중 하나가 일치하지 않습니다. 명령줄에서 mypy을 다시 실행하면 다음 오류가 표시됩니다.

(venv) $ mypy email_parser.py
email_parser.py:6: error: Incompatible return value type
⮑ (got "tuple[str, str]", expected "str | None") [return-value]
Found 1 error in 1 file (checked 1 source file)

이 메시지는 함수가 예상되는 단일 문자열 값 대신 두 개의 문자열이 포함된 튜플을 반환함을 나타냅니다. 이러한 정보는 런타임 시 치명적인 버그가 발생하는 것을 방지할 수 있기 때문에 매우 중요합니다.

유형 검사 외에도 mypy는 유형을 추론할 수 있습니다. mypy를 통해 스크립트를 실행할 때 표현식이나 변수를 가져올 필요 없이 reveal_type() 함수에 전달할 수 있습니다. 표현식의 유형을 추론하여 표준 출력 에 인쇄합니다.

유형 힌트로 함수에 주석을 달 때 더 까다로운 경우에 reveal_type()를 호출할 수 있습니다. 다음은 이 함수를 사용하여 parse_email() 반환 값의 실제 유형을 결정하는 방법에 대한 예입니다.

In [38]:
# email_parser.py

# ...

result = parse_email("claudia@realpython.com")
reveal_type(result)
---------------------------------------------------------------------------
NameError                                 Traceback (most recent call last)
Cell In[38], line 6
      1 # email_parser.py
      2 
      3 # ...
      5 result = parse_email("claudia@realpython.com")
----> 6 reveal_type(result)

NameError: name 'reveal_type' is not defined
  • 학습용이므로 이름을 정하지 않아서 에러가 발생합니다.

위의 예에서 result 변수에는 구문 분석된 이메일 주소의 두 구성 요소가 포함되어 있습니다. 이 변수를 reveal_type()함수에 전달하여 mypy유형을 추론할 수 있습니다. 콘솔에서 실행하는 방법은 다음과 같습니다.

(venv) $ mypy email_parser.py
email_parser.py:10: note: Revealed type is
⮑ "Union[tuple[builtins.str, builtins.str], None]"
Success: no issues found in 1 source file

 

mypy과 함께 Python 파일을 실행하면 스크립트의 reveal_type() 함수에서 유추된 유형이 인쇄됩니다. 이 예에서 mypy는 result 변수가 두 개의 문자열 또는 None의 빈 값을 포함하는 튜플이라고 올바르게 추론합니다.

IDE와 타사 정적 유형 검사기 도구는 개발 및 테스트 프로세스에서 유형 관련 오류를 포착할 수 있다는 점을 기억하세요. 이러한 도구는 반환 값에서 유형을 추론하고 함수가 예상 유형을 반환하는지 확인합니다.

더 복잡한 반환 유형의 유형 힌트를 표시할 수 있는 이 유용한 기능을 활용하십시오!