본문 바로가기
Computer Science/Python

Python 3.12 업데이트! 유용한 기능 살펴보기

by invrtd.h 2023. 10. 11.

 2023년 10월에 Python 3.12가 발표되었다. 개인적으로 이번 업데이트는 언어적 측면에서 새로운 기능을 많이많이 추가했다기보다는 부족한 부분을 보완하고 속도 향상에 초점을 맞춘 것 같다. 

 

 

PEP 695: 타입 매개변수 문법

Python에서 드디어 현대적인 스타일의 제네릭 프로그래밍 문법을 지원한다.

def max[T](args: Iterable[T]) -> T:
    ...

class list[T]:
    def __getitem__(self, index: int, /) -> T:
        ...

    def append(self, element: T) -> None:
        ...

출처: https://docs.python.org/ko/3/whatsnew/3.12.html

 

사용법은 다른 프로그래밍 언어의 제네릭 프로그래밍 사용법과 매우 닮아 있다. 예컨대 Scala도 다음 문법을 지원한다.

def get[T](list: Vector[T], idx: Int): T = ???

파이썬은 초기에 매우 작은 버전의 typing만 지원하려고 했던 것 같다. 그러나 3.12에 결국 언어 차원에서 제네릭 프로그래밍 문법이 추가된 것은 typing에 대한 수요가 파이썬이 이전에 생각했던 것보다 컸음을 방증한다. 이러다가 언젠가 static typing 강제하는 것도 나올지도?

 

Upper Bound

T에 특정 제약을 걸어줄 수 있다. 예컨대 my_class에 + 연산자를 오버로드하고 싶은데 오버로드 대상을 int, str 또는 int, str을 상속한 타입(즉, 서브타입)으로 제한하고 싶다면 다음과 같이 쓴다.

def __add__[T: (int, str)](rhs: T) -> Self:
    pass

변성 추론

주의: 타입 시스템에 익숙하지 않은 초보자에게 어려울 수 있습니다!

 

변성이란 타입 T가 타입 U의 서브타입일 때 C[T]가 C[U]의 서브타입일 수 있는가? 라는 질문과 관련 있다. 이 질문은 다른 말로 하면

  • T 타입 객체를 U 타입 객체로 볼 수 있다면(T is U), C[T] 타입 객체도 C[U] 타입 객체로 볼 수 있는가?
  • T 타입 객체가 U 타입 객체처럼 행동한다면(T behaviors as U), C[T] 타입 객체도 C[U] 타입 객체처럼 행동하는가?

와 비슷한 질문이다. (리스코프 치환 원칙은 상속을 표현하는 데 있어서 T is U보다는 T behaves as U가 더 적절한 표현이라고 주장한다.)

 

변성에는 3가지 종류, 각각 공변성, 무공변성, 반공변성이 있다.

 

예컨대 Bentley 타입과 Car 타입이 있고 Bentley가 Car를 상속했다고 가정하자. (이하 B, C) 그렇다면 B behaves as C가 성립한다. 그렇다면 list[Bentley] 타입도 list[Car] 타입처럼 행동한다고 볼 수 있을까? 그럴 수 있을 것 같다. 리스트 안에 있는 모든 Bentley 원소들이 Car처럼 행동하기 때문이다. 따라서 list[T] 타입의 T는 공변성을 띤다. 그러나 딕셔너리 dict[Bentley, V]는 dict[Car, V]처럼 행동하지 않는다. 따라서 dict[K, V] 타입의 K는 무공변성을 띤다. 함수 타입 Bentley -> int 타입도 마찬가지로 Car -> int 타입처럼 행동하지 않...는데 이번에는 Car -> int 타입이 Bentley -> int 타입처럼 행동한다고 볼 수 있다. 왜냐하면 다음 예제를 생각해 보자.

  • Car 클래스에 get_fuel 메서드가 정의되어 있다.
  • 함수 get_fuel은 다음과 같이 정의되어 있다.
def get_fuel[T: Car](car: T) -> int:
    return car.get_fuel()

 그런데 Car에 self.get_fuel()이 정의되어 있으므로 Bentley에도 self.get_fuel()이 정의되어 있다. 따라서 저 함수에서 Car을 Bentley로 바꾸어도 문제 될 부분이 없다. 즉 Car -> int 타입은 Bentley -> int 타입처럼 행동한다는 것이다. 이처럼 타입 C[X]가 T behaves as U일 때 반대로 C[U] behaves as C[T]가 성립한다면 X는 반공변성을 띤다.

 

 파이썬은 공변성을 인터프리터가 직접 추론한다. 따라서 프로그래머는 변성 추론이라는 개념을 알고는 있되 이를 직접 프로그램 작성에 활용할 필요는 없다.

 

타입 별칭

타입 별칭(type alias)은 긴 타입 이름에 대한 별칭을 지정해줄 때 사용하는 문법이다.

type Point = tuple[float, float]

type Point[T] = tuple[T, T]

type IntFunc[**P] = Callable[P, int]  # ParamSpec
type LabeledTuple[*Ts] = tuple[str, *Ts]  # TypeVarTuple
type HashableSequence[T: Hashable] = Sequence[T]  # TypeVar with bound
type IntOrStrSequence[T: (int, str)] = Sequence[T]  # TypeVar with constraints

출처: https://docs.python.org/ko/3/whatsnew/3.12.html

 

인터프리터는 타입 별칭의 값을 가능한 한 늦게 평가하려고 한다. 따라서 타입 별칭 정의문에 사용된 타입들이 타입 별칭보다 뒤에 선언되어도 된다.

type Couple = tuple[Human, Human]

class Human:
    pass

 

Python 3.12 이전에는

3.12 버전 이전의 제네릭 프로그래밍은 Python의 실제 문법이라기보다는 기존에 있던 Python 문법을 어떻게든 활용해서 만들어놓은 꼼수 같다는 느낌을 지울 수 없었다. 예를 들면...

X = TypeVar('X')
Y = TypeVar('Y')

def lookup_name(mapping: Mapping[X, Y], key: X, default: Y) -> Y:
    try:
        return mapping[key]
    except KeyError:
        return default

출처: 파이썬 공식 문서, class typing.Generic

 

이는 이름공간을 더럽힌다거나 하는 문제점을 낳는다. 해당 예제에서도, 나는 X라는 이름을 다른 곳에서 사용하고 싶은데 저기서 X라는 이름을 사용했으면 다른 곳에서 못 사용하는 건가?! 할 수도 있고, 다음 예제

T = TypeVar('T')

def f(x: T) -> T:
    return x
    
def g(y: T) -> T:
    return y

 에서 x와 y의 타입이 같아야 하나?! 하는 혼란을 부를 수도 있다. (사실 나도 저 불편한 기능을 써 본 적이 없어서, 저 예제가 어떤 결과를 내는지는 잘 모르겠다)

PEP 698: @override 데코레이터

C++11부터 있었던 그 override 키워드가 맞다.

이 데코레이터는 어떤 함수가 오버라이드되었음을 명시적으로 알려주는 일종의 documentation 역할을 한다. 또한 실제로 오버라이딩이 일어나지 않았을 경우 파이썬 정적 분석기는 이 사실을 프로그래머에게 알려줄 수도 있다.

from typing import override

class Base:
  def get_color(self) -> str:
    return "blue"

class GoodChild(Base):
  @override  # ok: overrides Base.get_color
  def get_color(self) -> str:
    return "yellow"

class BadChild(Base):
  @override  # type checker error: does not override Base.get_color
  def get_colour(self) -> str:
    return "red"

출처: https://docs.python.org/ko/3/whatsnew/3.12.html

PEP 692: **kwargs 타이핑 개선

이제 **kwargs 인자에도 타입 힌팅을 줄 수 있다.

from typing import TypedDict, Unpack

class Movie(TypedDict):
  name: str
  year: int

def foo(**kwargs: Unpack[Movie]): ...

하지만 파이썬 언어 수준에서 제공하는 것이 아니라 표준 라이브러리에 의존하기 때문에, 개인적으로는 문법이 좀 못생긴 것 같다. 굳이? 싶다.

PEP 701: f-string 문법 개선

파이썬의 f-string은 간편한 문자열 포매팅을 도와주는 문법으로, 문자열 내부에 작은 파이썬 문법을 넣어서 직관적이라는 것이 큰 장점이다. 이번 f-string 개선은 기존에 문법의 모호함으로 인해 인터프리터가 오류를 호소하던 몇몇 불편함을 고쳤다.

songs = ['Take me back to Eden', 'Alkaline', 'Ascensionism']
f"This is the playlist: {", ".join(songs)}"
'This is the playlist: Take me back to Eden, Alkaline, Ascensionism'

출처: https://docs.python.org/ko/3/whatsnew/3.12.html

3.11까지 이 문법은 오류였다. 왜냐하면 f-string이 큰따옴표로 감싸져 있는데 대괄호({}) 안에 들어 있는 문자도 큰따옴표였기 때문이다. 이렇게 되면 사실 해석 방법에 대한 규칙을 정확히 정해놓지 않는다면 해당 예제는 다음과 같이 해석될 수도 있다.

f"This is the playlist: {", 
".join(songs)}"

이런 문제는 규칙을 제대로 정한다면 피해갈 수 있다. 

f"This is the playlist: {", ".join([
    'Take me back to Eden',  # My, my, those eyes like fire
    'Alkaline',              # Not acid nor alkaline
    'Ascensionism'           # Take to the broken skies at last
])}"

출처: https://docs.python.org/ko/3/whatsnew/3.12.html

 

또 이제 f-string 내부의 표현식이 여러 줄이 되는 것이 허용된다. 안에 주석을 달 수도 있다.

>>> print(f"This is the playlist: {"\n".join(songs)}")
This is the playlist: Take me back to Eden
Alkaline
Ascensionism
>>> print(f"This is the playlist: {"\N{BLACK HEART SUIT}".join(songs)}")
This is the playlist: Take me back to Eden♥Alkaline♥Ascensionism

출처: https://docs.python.org/ko/3/whatsnew/3.12.html

 

이제 f-string 내부에서 백슬래시 문자를 쓰는 것이 허용된다. 이것은 제어 문자('\n' 등)나 백슬래시를 통해 입력 가능한 유니코드 문자를 입력할 수 있다는 것을 의미한다.

PEP 684: 전역 인터프리터 락

동시성/병렬성을 지닌 프로그램에서 한 번에 하나의 스레드만 파이썬 바이트 코드에 접근할 수 있도록 하는 구현 방식인 GIL(Global Interpreter Lock)을 3.12에 적용했다. 그냥 빨라졌다고 생각하면 될 것 같다. 무어의 법칙이 깨진 오늘날 많은 기업이 컴퓨터의 속도를 향상시키는 방법으로 동시성을 택하고 있는데 파이썬도 이 흐름을 따라가고 있다.

PEP 669: (CPython) 프로그램 모니터링 비용 감소

디버거, 프로파일러 같이 프로그램 내부 흐름이 어떻게 돌아가는지 찍어 주는 프로그램을 사용한다면 당연히 성능이 느려질 수밖에 없다. 3.12에서는 "You only pay for what you use" 법칙을 지킨 프로그램 모니터링 API를 제공한다. You only pay for what you use는 사용하지 않는 기능에 대해서는 비용을 지불하지 않는다는 뜻인데, 이를 지키지 않는 기술로는 C++8x 시절의 예외 처리가 유명하다. 이 당시 C++은 예외를 사용하기로 컴파일러에게 알려주면 실제로 단 한 번도 예외를 던지지 않아도 프로그램 실행 시간이 느려졌었다. C++11 들어서는 거의 그러지 않는다.

PEP 709: Comprehension 인라인화

인라인화란 함수가 작다면 컴파일러가 함수 호출문을 그대로 그 함수의 정의로 치환하는 기술이다. 인라인화를 하는 이유는 함수 호출에도 다 비용이 들기 때문이다. 함수가 작다면 함수 호출 시간이 함수 실행 시간보다 더 길어지는, 즉 배보다 배꼽이 더 큰 상황도 일어날 수 있다. Comprehension 인라인화는 다음과 같은 상황에서 함수를 인라인화해준다.

new_list = [x for x in lst]

출처: https://peps.python.org/pep-0709/

여기에 함수는 없어 보이지만 착각이다! (A) for (arg) in (iter) 문법에서 (A) 부분은 사실 arg에 대한 함수다. x를 따로 정의하지 않고도 comprehension 문의 (A) 부분에서 x를 쓸 수 있는 이유가 바로 x가 arg에 대한 함수기 때문이다. 3.11까지 이 문법은 리스트를 만들 때마다 자기 자신을 리턴하는 함수(말하자면, def f(x): return x)를 일일이 호출하면서 리스트를 만들었지만, 3.12부터는 여기서 함수가 인라인화되기 때문에 함수 호출 비용을 크게 줄일 수 있다.

에러 메시지 변화

예뻐졌겠지~~ 에러 메시지 변화는 파이썬 개발자들이 할 일이지 우리의 일이 아니기 때문에 솔직히 관심없다.

 

 

 

댓글