Programming/Python

[Python] 파이썬 3.11 기능 소개

Jonghyuk Baek 2022. 8. 15. 23:41

Introduction

파이썬 3.10 기능 소개를 한 지 얼마 지나지 않은 것 같은데, 슬슬 3.11 버전에 대한 소식도 들려오고 있다. 이번 글에서는 3.10 버전과 비교해 3.11 버전에서는 어떤 기능들이 새롭게 추가 되었는지 소개하고자 한다.

 

이 글은 3.11.0b4 릴리즈를 기준으로 한다. 설치는 이 링크를 참고하자.

 

예제와 표의 일부는 공식 도큐멘테이션에서 참고했다.

 

(이전 글)

[Python] 파이썬 3.10 기능 소개


1. Faster CPython

파이썬 3.11 버전은 이전 버전에 비해 (3.10) 전반적인 실행 속도가 10%~60% 정도, 평균적으로는 25% 정도 빨라졌다고 한다. 설명만으로는 와닿지 않으니 한번 테스트를 해 보자.

import time


def timer(function):
    def tictoc(*args, **kwargs):
        start = time.time()
        function(*args, **kwargs)
        print(f"Elapsed time: {(time.time() - start) * 1000} ms.")
    return tictoc


@timer
def append_list(n_iter):
    temp_list = []
    for i in range(n_iter):
        temp_list.append(i)


# On 3.10.0b3
>>> append_list(2500000)
Elapsed time: 204.00071144104004 ms.

# On 3.11.0a1
>>> append_list(2500000)
Elapsed time: 160.99786758422852 ms.

단순한 list append 작업을 기준으로 약 21.08%의 시간 성능 개선이 있음을 확인했다. 디바이스마다 다르겠지만, 공식적으로 언급된 수치에 얼추 들이맞는 개선 수치이다.

 

참고로 제목에서 언급된 CPython은 C 언어로 작성된 파이썬 인터프리터로, 파이썬 프로그램을 실행시킬 때 기본으로 사용되는 인터프리터이다. 주로 쓰이는 다른 인터프리터로는 PyPy, Cython, Jython 등이 있는데, Faster CPython 이라는 말은 다시 말해 디폴트가 아닌 다른 종류의 인터프리터를 사용할 경우 시간 성능 개선을 체감할 수 없을 것이란 뜻이 된다....

 

PEP 659: Specializing Adaptive Interpreter

크게 두 가지 부분: 프로그램 startup, 그리고 프로그램 runtime 부분에 에 대해서 주된 시간 성능 개선이 있었다고 한다. 특히 런타임 시간 성능에 대해서는, 프로그램 실행 중 동적으로 타입이 변경되지 않는 부분들 (이를 type stability라 부른다) 을 자동으로 탐지하고 이 부분이 operation을 특정 타입에 더 특화된 operation으로 치환 (specialization) 하는 방식으로 시간 절감을 이루어 냈다고 한다.

 

특화된 오퍼레이션으로의 치환은 type stability를 유지하면서 반복적으로 실행되는 코드에 대해서만 이루어지고, 런타임 중에 주기적으로 specialization을 시도하면서 오퍼레이션을 specialize 하거나 반대로 더 이상 조건에 맞지 않을 경우 de-specialize 하게 된다.

 

아래 표는 specialization이 이루어지는 operation들과 그 쓰임새, 그리고 속도 개선 수치를 보여준다.

 

Operation
Form Specialization Operation speedup (up to) Contributor(s)
Binary operations x+x; x*x; x-x; Binary add, multiply and subtract for common types such as int, float, and str take custom fast paths for their underlying types. 10% Mark Shannon, Dong-hee Na, Brandt Bucher, Dennis Sweeney
Subscript a[i] Subscripting container types such as list, tuple and dict directly index the underlying data structures.
Subscripting custom __getitem__ is also inlined similar to Inlined Python function calls.
10-25% Irit Katriel, Mark Shannon
Store subscript a[i] = z Similar to subscripting specialization above. 10-25% Dennis Sweeney
Calls f(arg) C(arg) Calls to common builtin (C) functions and types such as len and str directly call their underlying C version. This avoids going through the internal calling convention. 20% Mark Shannon, Ken Jin
Load global variable print len The object’s index in the globals/builtins namespace is cached. Loading globals and builtins require zero namespace lookups. 1 Mark Shannon
Load attribute o.attr Similar to loading global variables. The attribute’s index inside the class/object’s namespace is cached. In most cases, attribute loading will require zero namespace lookups. 2 Mark Shannon
Load methods for call o.meth() The actual address of the method is cached. Method loading now has no namespace lookups – even for classes with long inheritance chains. 10-20% Ken Jin, Mark Shannon
Store attribute o.attr = z Similar to load attribute optimization. 2% in pyperformance Mark Shannon
Unpack Sequence *seq Specialized for common containers such as list and tuple. Avoids internal calling convention. 8% Brandt Bucher

1 A similar optimization already existed since Python 3.8. 3.11 specializes for more forms and reduces some overhead.

2 A similar optimization already existed since Python 3.10. 3.11 specializes for more forms. Furthermore, all attribute loads should be sped up by bpo-45947.

 

위 표는 공식 문서에서 참고했다. 더 자세한 내용은 PEP의 형태로 따로 문서화가 되어 있으니 참고하자.

 

2. Exception Groups and except *

3.11 버전부터는 ExceptionGroup 이라는 빌트인 예외 타입이 추가되어, 여러 종류의 exception을 동시에 raise 하거나 handle 할 수 있게 되었다. 여러 동작을 동시에 실행시키거나, 여러 번의 실행 시도 후 에러 리포트를 해야 하는 등 많은 수의 서로 다른 예외를 한꺼번에 다루어야 하는 상황에 쓸 수 있는 도구이다.

 

Getting Started

eg = ExceptionGroup(
    "Root exception",
    [
        RuntimeError("Sample runtime error."),
        ValueError("Sample value error."),
    ]
)

>>> import traceback
>>> traceback.print_exception(eg)
  | ExceptionGroup: Root exception (2 sub-exceptions)
  +-+---------------- 1 ---------------- 
    | RuntimeError: Sample runtime error.
    +---------------- 2 ---------------- 
    | ValueError: Sample value error.    
    +------------------------------------

정의할 때에는 위와 같이 에러 메시지 스트링과 nest 시킬 예외들의 sequence를 ExceptionGroup에 전달해 주면 된다.

eg = ExceptionGroup(
    "Root exception",
    [
        RuntimeError("Sample runtime error."),
        ValueError("Sample value error."),
        TypeError("Sample type error.")
    ]
)

>>> print(eg.exceptions)
(RuntimeError('Sample runtime error.'), ValueError('Sample value error.'), TypeError('Sample type error.'))

참고로 개별 exception들은 ExceptionGroup.exceptions 의 형태로 하나씩 접근하는 것도 가능하다.

 

Subgroup

ExceptionGroup에서 특정 타입의 예외만 골라내고 싶을 수도 있는데, 이때는 ExceptionGroup.subgroup(condition) 메서드를 사용할 수 있다. 아래의 예시를 보자.

eg = ExceptionGroup(
    "Root exception",
    [
        RuntimeError("Sample runtime error."),
        ValueError("Sample value error."),
        TypeError("Sample type error 1."),
        TypeError("Sample type error 2."),
    ]
)

>> import traceback
>> type_errors = eg.subgroup(lambda e: isinstance(e, TypeError))
>> traceback.print_exception(type_errors)
  | ExceptionGroup: Root exception (2 sub-exceptions)
  +-+---------------- 1 ---------------- 
    | TypeError: Sample type error 1.    
    +---------------- 2 ---------------- 
    | TypeError: Sample type error 2.    
    +------------------------------------

그룹에서 TypeError만을 모아 exception subgroup을 구성할 수 있다. 기존 그룹이 여러 계층으로 되어 있어도 똑같이 계층 구조를 유지하는 방향으로 작동한다 (Group 내에서 다시 group을 정의).

 

Split

여기서 subgroup이 아닌 split 메서드를 사용하면 조건에 맞는 exception과 그렇지 않은 exception의 그룹을 각각 만들어 주니 참고하자. 아래의 예시 참고.

 

Catching ExceptionGroup

eg = ExceptionGroup(
    "Root exception",
    [
        RuntimeError("Sample runtime error."),
        ValueError("Sample value error."),
        TypeError("Sample type error 1."),
        TypeError("Sample type error 2."),
    ]
)

if __name__ == "__main__":
    import traceback

    try:
        raise eg
    except ExceptionGroup as eg:
        type_errors, other_errors = eg.split(lambda e: isinstance(e, TypeError))
        traceback.print_exception(type_errors)
        traceback.print_exception(other_errors)

>>>
  + Exception Group Traceback (most recent call last):
  |   File "...\main.py", line 14, in <module>
  |     raise eg
  | ExceptionGroup: Root exception (2 sub-exceptions)
  +-+---------------- 1 ----------------
    | TypeError: Sample type error 1.
    +---------------- 2 ----------------
    | TypeError: Sample type error 2.
    +------------------------------------
  + Exception Group Traceback (most recent call last):
  |   File "...\main.py", line 14, in <module>
  |     raise eg
  | ExceptionGroup: Root exception (2 sub-exceptions)
  +-+---------------- 1 ----------------
    | RuntimeError: Sample runtime error.
    +---------------- 2 ----------------
    | ValueError: Sample value error.
    +------------------------------------

ExceptionGroup은 기존의 다른 예외 클래스와 같이 except ~~~ as ~~~ 의 형태로 핸들링 할 수 있다. 하지만 매번 ExceptionGroup이 레이즈 될 것을 기대하고 ExceptionGroup을 catch 하도록 하는 것도 상당히 번거로운 일인데, 이를 위해서 추가된 기능이 아래에 있다.

 

exception *

여러 개의 exception이 한번에 전달될 때, ExceptionGroup을 catch 하는 대신 아래와 같이 except * 구문을 이용해 한번에 여러 exception을 동시에 핸들할 수 있게 되었다.

try:
    ...
except* SpamError:
    ...
except* FooError as e:
    ...
except* (BarError, BazError) as e:
    ...

실제로 사용해 보면 아래의 느낌이 된다.

eg = ExceptionGroup(
    "Root exception",
    [
        RuntimeError("Sample runtime error."),
        ValueError("Sample value error."),
        TypeError("Sample type error 1."),
        SyntaxError("Sample type error 2."),
    ]
)

>>>     try:
...         raise eg
...     except* ValueError as eg:
...         for e in eg.exceptions:
...             print(type(e).__name__)
...     except* (RuntimeError, TypeError) as eg:
...         for e in eg.exceptions:
...             print(type(e).__name__)


ValueError
RuntimeError
TypeError
  + Exception Group Traceback (most recent call last):
  |   File "...\main.py", line 13, in <module>
  |     raise eg
  | ExceptionGroup: Root exception (1 sub-exception)
  +-+---------------- 1 ----------------
    | SyntaxError: Sample type error 2.
    +------------------------------------

조건에 맞는 예외들은 ExceptionGroup으로 싸여 전달되고, 만약 처리되지 않은 예외가 끝까지 남아 있다면 위와 같이 나머지만 묶여서 한번에 레이즈 된다.

 

3. Enriching Exceptions with Notes

>>> try:
...     raise TypeError('bad type')
... except Exception as e:
...     e.add_note('Add some information')
...     raise
...

Traceback (most recent call last):
  File "<stdin>", line 2, in <module>
TypeError: bad type
Add some information

Exception을 catch 하고 다시 re-raise 하거나, ExceptionGroup에 추가하는 경우 해당 exception의 에러 트레이스백에 추가적인 정보를 더 담을 수 있도록 BaseException.add_note 메서드가 추가되었다.

 

4. Include Fine Grained Error Locations in Tracebacks

더욱 편한 디버그를 위해 에러 발생 위치를 더 정확히 출력해 주도록 변경되었다.

def divide_a_with_b(a, b):
    return a / b


# On 3.10.0b3
>>> divide_a_with_b(1, 0)
Traceback (most recent call last):
  File "..\main.py", line 5, in <module>
    divide_a_with_b(1, 0)
  File "..\main.py", line 2, in divide_a_with_b
    return a / b
ZeroDivisionError: division by zero


# On 3.11.0b4
>>> divide_a_with_b(1, 0)
Traceback (most recent call last):
  File "..\main.py", line 5, in <module>
    divide_a_with_b(1, 0)
  File "..\main.py", line 2, in divide_a_with_b
    return a / b
           ~~^~~
ZeroDivisionError: division by zero

단순 에러 발생 라인만 알려주던 3.10과 달리, 정확한 에러 발생 지점을 출력해 주고 있다.

sample_dict = {
    "a": 0,
    "b": 1,
    "c": 2,
    "d": 3,
}

>>> print(sample_dict["a"] + sample_dict["b"] + sample_dict["e"])
Traceback (most recent call last):
  File "..\main.py", line 8, in <module>
    print(sample_dict["a"] + sample_dict["b"] + sample_dict["e"])
                                                ~~~~~~~~~~~^^^^^
KeyError: 'e'

보통 에러가 발생한 라인이 길면 길수록, 에러가 발생한 오퍼레이션이 어딘지 반 직감으로 때려 맞추어야 하는 상황이 종종 있었는데, 이제는 정확히 어떤 위치에서 에러가 발생했는지 알려 줘 좀 더 편할 것 같다.

 

5. Self Type

self 를 반환하는 등 현재의 클래스와 같은 타입의 클래스를 반환하는 메서드에 사용할 수 있는 Self 타입 힌트가 추가되었다. 이 타입이 추가된 배경에 대해서는 아래의 예시를 한번 살펴보자.

class Shape:
    def set_scale(self, scale: float) -> Shape:
        self.scale = scale
        return self

Shape().set_scale(0.5)  # => Shape

기존에는 self를 반환하는 경우, 위와 같이 단순히 반환 타입에 현재의 클래스를 명시해 놓는 식으로 타입 힌트를 지정해 줄 수 있었다.

class Circle(Shape):
    def set_radius(self, r: float) -> Circle:
        self.radius = r
        return self

Circle().set_scale(0.5)  # *Shape*, not Circle
Circle().set_scale(0.5).set_radius(2.7)
# => Error: Shape has no attribute set_radius

하지만 이런 방식은 해당 클래스를 상속받는 하위 클래스를 사용할 때에 타입 체커 쪽에서 문제를 발생시켰다. 부모 클래스 메서드에 명시적으로 부모 클래스를 반환 타입으로 지정해 놓았기 때문에, 이를 상속받는 자식 클래스에서 해당 메서드를 호출할 때에도 자식 클래스가 아닌 부모 클래스가 반환된다고 인식하면서, 자식 클래스 메서드를 뒤이어 호출할 때 타입 에러를 발생시키는 것이였다.

from typing import Self

class Shape:
    def set_scale(self, scale: float) -> Self:
        self.scale = scale
        return self


class Circle(Shape):
    def set_radius(self, radius: float) -> Self:
        self.radius = radius
        return self

해당 문제를 해결하기 위해 3.11 버전에서 Self 타입이 추가되면서, 자기 자신을 return 하는 경우를 더 직관적으로 표현하면서 비정상적인 동작도 막을 수 있게 되었다.

 


이 이외에도 TypeDict 타입에 Required, NotRequired를 명시해 줄 수 있게 된 PEP 655 라던지, 여기서 소개하지 못한 변경 사항이나 변경사항 별 세부 내용들이 많이 남아있는데, 중요한 부분들에 대해서는 대략적으로 훑었으니 나머지 부분들이 궁금하다면 공식 도큐멘테이션 을 참고하자.

 

아래는 참고할 만한 페이지들이다.

References

What’s New In Python 3.11

https://docs.python.org/ko/3.11/whatsnew/3.11.html

Python 3.11.0b4 Pre-release

https://www.python.org/downloads/release/python-3110b4/

PEP 659 – Specializing Adaptive Interpreter

https://peps.python.org/pep-0659/

PEP 654 – Exception Groups and except*

https://peps.python.org/pep-0654/

PEP 657 – Include Fine Grained Error Locations in Tracebacks

https://peps.python.org/pep-0657/

PEP 655 – Marking individual TypedDict items as required or potentially-missing

https://peps.python.org/pep-0655/

PEP 673 – Self Type

https://peps.python.org/pep-0673/

 

반응형