Following system colour scheme Selected dark colour scheme Selected light colour scheme

Python 개선 제안 한국어 번역

PEP 698 – 정적 타이핑을 위한 오버라이드 데코레이터

Author:
Steven Troxler <steven.troxler at gmail.com>, Joshua Xu <jxu425 at fb.com>, Shannon Zhu <szhu at fb.com>
Sponsor:
Jelle Zijlstra <jelle.zijlstra at gmail.com>
Discussions-To:
Discourse thread
Status:
Final
Type:
Standards Track
Topic:
Typing
Created:
05-Sep-2022
Python-Version:
3.12
Post-History:
20-May-2022, 17-Aug-2022, 11-Oct-2022, 07-Nov-2022
Resolution:
Discourse message

Table of Contents

번역·라이선스 안내

이 비공식 한국어 번역은 원문 Copyright 절의 Public Domain or CC0-1.0, whichever is more permissive 조건에 따라 제공합니다. 원저자와 공식 원문은 그대로 표시합니다. 수정되지 않은 기준 원문 · 공식 최신판

Important

This PEP is a historical document: see @override and @typing.override for up-to-date specs and documentation. Canonical typing specs are maintained at the typing specs site; runtime typing behaviour is described in the CPython documentation.

×

See the typing specification update process for how to propose changes to the typing spec.

초록

이 PEP는 Python 타입 시스템에 @override 데코레이터를 추가할 것을 제안합니다. 이를 통해 타입 검사기는 베이스 클래스가 파생 클래스에 상속되는 메서드를 변경할 때 발생하는 부류의 버그를 방지할 수 있습니다.

동기

타입 검사기의 주요 목적 중 하나는 리팩터링이나 변경으로 인해 코드에 기존에 있던 의미 구조가 깨질 때 이를 플래그로 표시하는 것입니다. 그러면 사용자는 코드를 수동으로 감사하지 않고도 프로젝트 전반에서 문제를 파악하고 수정할 수 있습니다.

안전한 리팩터링

Python의 타입 시스템은 오버라이드된 함수의 API가 변경될 때 일관성을 유지하기 위해 변경해야 하는 호출 지점을 식별하는 방법을 제공하지 않습니다. 이 때문에 코드를 리팩터링하고 변환하는 일이 더 위험해집니다.

다음과 같은 간단한 상속 구조를 생각해 보십시오:

class Parent:
    def foo(self, x: int) -> int:
        return x

class Child(Parent):
    def foo(self, x: int) -> int:
        return x + 1

def parent_callsite(parent: Parent) -> None:
    parent.foo(1)

def child_callsite(child: Child) -> None:
    child.foo(1)

슈퍼클래스에서 오버라이드된 메서드의 이름이 바뀌거나 삭제되면, 타입 검사기는 베이스 타입을 직접 다루는 호출 지점을 갱신하라고만 알려 줍니다. 그러나 타입 검사기는 우리가 가한 변경이 아니라 새 코드만 볼 수 있으므로, 자식 클래스에 있는 같은 메서드의 이름도 바꿔야 했을 가능성이 높다는 사실을 알 방법이 없습니다.

우리가 버그를 만들어 내고 있을 가능성이 높은데도 타입 검사기는 이 코드를 아무 문제 없이 받아들입니다:

class Parent:
    # Rename this method
    def new_foo(self, x: int) -> int:
        return x

class Child(Parent):
    # This (unchanged) method used to override `foo` but is unrelated to `new_foo`
    def foo(self, x: int) -> int:
        return x + 1

def parent_callsite(parent: Parent) -> None:
    # If we pass a Child instance we’ll now run Parent.new_foo - likely a bug
    parent.new_foo(1)

def child_callsite(child: Child) -> None:
    # We probably wanted to invoke new_foo here. Instead, we forked the method
    child.foo(1)

이 코드는 타입 검사를 통과하지만, 두 가지 잠재적인 버그 원인이 있습니다:

  • Child 인스턴스를 parent_callsite 함수에 전달하면, Child.foo가 아니라 Parent.new_foo에 있는 구현이 호출됩니다. 이는 아마도 버그일 것입니다. 사용자 정의 동작이 필요하지 않았다면 애초에 Child.foo를 작성하지 않았을 것이기 때문입니다.
  • 우리 시스템은 Child.foo가 Parent.foo와 비슷하게 동작한다는 점에 의존하고 있었을 가능성이 높습니다. 그러나 이를 일찍 잡아내지 못하면 이제 두 메서드는 갈라진 상태가 되며, 향후 리팩터링에서 new_foo의 동작을 크게 바꿀 때 Child.foo도 함께 갱신해야 할 가능성이 높다는 사실을 아무도 깨닫지 못할 수 있고, 이는 나중에 심각한 버그로 이어질 수 있습니다.

잘못 리팩터링된 코드는 타입 안전하지만, 아마도 우리가 의도한 것이 아니며 시스템이 잘못 동작하게 만들 수 있습니다. 새 코드가 예외를 던지지 않고 실행될 가능성이 높기 때문에 이 버그는 추적하기 어려울 수 있습니다. 테스트로 문제를 잡아낼 가능성도 낮으며, 조용히 발생하는 오류는 프로덕션에서 추적하는 데 더 오랜 시간이 걸릴 수 있습니다.

우리는 이러한 잘못된 리팩터링으로 인해 타입이 지정된 여러 코드베이스에서 프로덕션 장애가 여러 차례 발생했다는 사실을 알고 있습니다. 이것이 타입 시스템에 @override 데코레이터를 추가하려는 주된 동기입니다. 이 데코레이터를 사용하면 개발자가 Parent.foo와 Child.foo 사이의 관계를 표현할 수 있으므로 타입 검사기가 문제를 감지할 수 있습니다.

근거

서브클래스 구현이 더 명시적이 됩니다

우리는 명시적인 오버라이드가 암묵적인 오버라이드보다 낯선 코드를 더 읽기 쉽게 만들어 줄 것이라고 믿습니다. @override를 사용하는 서브클래스의 구현을 읽는 개발자는 어떤 메서드가 베이스 클래스의 기능을 오버라이드하고 있는지 즉시 알 수 있습니다. 이 데코레이터가 없으면 이를 빠르게 알아내는 유일한 방법은 정적 분석 도구를 사용하는 것입니다.

다른 언어와 런타임 라이브러리의 선례

다른 언어의 정적 오버라이드 검사

널리 사용되는 많은 프로그래밍 언어가 오버라이드 검사를 지원합니다. 예를 들면 다음과 같습니다:

Python의 런타임 오버라이드 검사

현재 @overrides [sic]와 @final 데코레이터를 제공하고 이를 런타임에 강제하는 Overrides 라이브러리가 있습니다.

PEP 591은 Overrides 라이브러리의 것과 동일한 의미를 가진 @final 데코레이터를 추가했습니다. 그러나 런타임 라이브러리의 오버라이드 구성 요소는 정적으로 전혀 지원되지 않으며, 이로 인해 지원이 뒤섞여 있는 상황을 둘러싸고 혼란이 가중되었습니다.

정적 검사에서 @override를 지원하면 다음과 같은 이유로 가치가 있습니다:

  • 버그를 더 일찍, 흔히 편집기 안에서 잡을 수 있습니다.
  • 정적 검사는 런타임 검사와 달리 성능 오버헤드가 없습니다.
  • 거의 사용되지 않는 모듈에서도 버그를 빠르게 잡을 수 있는 반면, 런타임 검사에서는 모든 임포트에 대한 자동화된 테스트가 없으면 이러한 버그가 한동안 발견되지 않을 수 있습니다.

단점

@override를 사용하면 코드가 더 장황해집니다.

명세

타입 검사기는 @typing.override로 데코레이트된 메서드를 만났을 때, 그 메서드가 어떤 조상 클래스의 호환되는 메서드나 속성을 오버라이드하는 경우가 아니라면 이를 타입 오류로 처리해야 합니다.

from typing import override

class Parent:
    def foo(self) -> int:
        return 1

    def bar(self, x: str) -> str:
        return x

class Child(Parent):
    @override
    def foo(self) -> int:
        return 2

    @override
    def baz(self) -> int:  # Type check error: no matching signature in ancestor
        return 1

@override 데코레이터는 타입 검사기가 메서드를 유효한 오버라이드로 간주하는 모든 곳에서 허용되어야 하며, 여기에는 일반적으로 일반 메서드뿐 아니라 @property, @staticmethod, @classmethod도 포함됩니다.

오버라이드 호환성에 대한 새로운 규칙 없음

이 PEP는 오직 새로운 @override 데코레이터의 처리만을 다루며, 이 데코레이터는 데코레이트된 메서드가 조상 클래스의 어떤 속성을 반드시 오버라이드해야 함을 지정합니다. 이 PEP는 그러한 메서드의 타입 시그니처에 관한 새로운 규칙을 제안하지 않습니다.

프로젝트별 엄격한 강제

저희는 부모 클래스를 오버라이드하는 메서드에 데코레이터 사용을 요구하는 엄격 모드를 개발자가 선택적으로 활성화할 수 있도록 검사기가 허용할 때 @override가 가장 유용하다고 생각합니다. 하위 호환성을 위해 엄격한 강제는 선택적으로 활성화하는 방식이어야 합니다.

동기

@override를 요구하는 엄격 모드가 필요한 주된 이유는, 개발자가 프로젝트 전체에서 @override 데코레이터가 사용된다는 것을 알고 있을 때에만 리팩터링이 오버라이드 측면에서 안전하다고 신뢰할 수 있기 때문입니다.

오버라이드와 관련하여 엄격 모드를 사용해야만 잡을 수 있는 또 다른 종류의 버그가 있습니다.

다음 코드를 살펴보십시오.

class Parent:
    pass

class Child(Parent):
    def foo(self) -> int:
        return 2

이 코드를 다음과 같이 리팩터링한다고 가정해 봅시다.

class Parent:
    def foo(self) -> int:   # This method is new
        return 1

class Child(Parent):
    def foo(self) -> int:  # This is now an override!
        return 2

def call_foo(parent: Parent) -> int:
    return parent.foo()  # This could invoke Child.foo, which may be surprising.

여기서 코드의 의미가 바뀌었으며, 이로 인해 두 가지 문제가 생길 수 있습니다:

  • 코드를 변경한 사람이 Child.foo가 이미 존재한다는 것을 몰랐다면(대규모 코드베이스에서는 충분히 있을 수 있는 일입니다), call_foo가 항상 Parent.foo를 호출하지는 않는다는 것을 보고 놀랄 수 있습니다.
  • 코드베이스 작성자들이 서브클래스에서 오버라이드를 작성할 때마다 @override를 수동으로 적용하려 했다면, 여기서 Child.foo에도 그것이 필요하다는 사실을 놓치기 쉽습니다.

언뜻 보기에는 이런 종류의 변경이 드물 것처럼 보일 수 있습니다. 하지만 하나 이상의 서브클래스에 있는 기능이 사실은 베이스 클래스에 있어야 한다는 것을 개발자가 나중에 깨닫는 경우, 실제로는 자주 일어날 수 있습니다.

엄격 모드를 사용하면 이런 일이 발생할 때 항상 개발자에게 알립니다.

선례

저희가 살펴본 타입이 있는 객체 지향 프로그래밍 언어 대부분은 프로젝트 전체에 걸쳐 명시적 오버라이드를 요구하는 쉬운 방법을 제공합니다:

  • C#, Kotlin, Scala, Swift는 항상 명시적 오버라이드를 요구합니다
  • TypeScript에는 명시적 오버라이드를 강제하는 –no-implicit-override 플래그가 있습니다
  • Hack과 Java에서는 타입 검사기가 오버라이드를 항상 옵트인으로 취급하지만, 널리 쓰이는 린터는 명시적 오버라이드가 빠진 경우 경고할 수 있습니다.

하위 호환성

기본적으로 @override 데코레이터는 옵트인 방식입니다. 이를 사용하지 않는 코드베이스는 추가적인 타입 안전성 없이 이전과 동일하게 타입 검사됩니다.

런타임 동작

가능한 경우 __override__ = True로 설정

런타임에 @typing.override는 인자에 값이 True인 __override__ 속성을 추가하기 위해 최선을 다해 시도합니다. “최선을 다한다”는 것은 속성 추가를 시도하되, 실패하면(예를 들어 입력이 고정된 슬롯을 가진 디스크립터 타입인 경우) 아무 알림 없이 인자를 그대로 반환한다는 의미입니다.

이는 @typing.final 데코레이터가 하는 일과 정확히 같으며, 동기도 비슷합니다. 런타임 라이브러리가 @override를 활용할 수 있게 해 줍니다. 구체적인 예로, 런타임 라이브러리는 __override__를 확인하여 부모 메서드의 독스트링으로 자식 클래스 메서드의 __doc__ 속성을 자동으로 채울 수 있습니다.

__override__ 설정의 한계

앞서 설명한 것처럼 런타임에 __override__ 추가가 실패할 수 있으며, 그런 경우에는 단순히 인자를 그대로 반환합니다.

또한 추가가 성공하는 경우에도, 최종 결과물에 __override__ 속성이 설정되도록 확실히 보장하려면 각 데코레이터의 구현을 이해해야 하므로, 사용자가 여러 데코레이터를 올바르게 함께 사용하기 어려울 수 있습니다:

  • 가장 바깥쪽 래퍼에 __override__를 설정해야 하므로, @override 데코레이터는 @functools.lru_cache처럼 래퍼 함수를 사용하는 일반 데코레이터보다 나중에 실행되어야 합니다. 즉, 이러한 다른 모든 데코레이터보다 위에 위치해야 합니다.
  • 하지만 @override는 @property, @staticmethod, @classmethod와 같은 여러 특수한 디스크립터 기반 데코레이터보다는 먼저 실행되어야 합니다.
  • 앞서 논의한 것처럼, 일부 경우(예를 들어 고정된 슬롯을 가진 디스크립터나 래핑도 하는 디스크립터)에는 __override__ 속성을 아예 설정할 수 없을 수도 있습니다.

따라서 __override__ 설정에 대한 런타임 지원은 최선을 다하는 수준에 그치며, 저희는 타입 검사기가 데코레이터의 순서를 검증할 것으로 기대하지 않습니다.

기각된 대안

안전성을 위해 통합 개발 환경에 의존하기

최신 통합 개발 환경(IDE)은 메서드 이름을 바꿀 때 서브클래스를 자동으로 갱신하는 기능을 제공하는 경우가 많습니다. 하지만 저희는 다음과 같은 여러 이유로 이것만으로는 충분하지 않다고 봅니다:

  • 코드베이스가 여러 프로젝트로 나뉘어 있으면 IDE는 도움이 되지 않으며, 버그는 의존성을 업그레이드할 때 나타납니다. 타입 검사기를 사용하면 의존성에서 호환성을 깨뜨리는 변경을 빠르게 잡아낼 수 있습니다.
  • 모든 개발자가 그런 IDE를 사용하는 것은 아닙니다. 또한 라이브러리 유지 관리자는 IDE를 사용하더라도 풀 리퀘스트 작성자가 같은 IDE를 사용한다고 가정할 필요가 없어야 합니다. 저희는 개발자가 어떤 편집기를 선택했는지 전혀 가정하지 않고도 지속적 통합에서 문제를 감지할 수 있는 방식을 선호합니다.

런타임 강제

저희는 @overrides.overrides가 현재 하고 있는 것과 비슷하게 @typing.override가 런타임에 재정의 안전성을 강제하도록 하는 방안을 검토했습니다.

저희는 다음 네 가지 이유로 이 방안을 채택하지 않았습니다:

  • 정적 타입 검사를 사용하는 사용자에게 이것이 어떤 이점을 가져다주는지 분명하지 않습니다.
  • 적어도 어느 정도의 성능 오버헤드가 발생하므로, 런타임 강제를 사용하면 프로젝트의 임포트가 느려집니다. 저희는 @overrides.overrides 구현에 약 100마이크로초가 걸린다고 추정합니다. 이는 빠른 편이지만 백만 줄이 넘는 코드베이스에서는 추가 초기화 시간이 누적되어 1초 이상이 될 수 있으며, 저희는 바로 그런 코드베이스에서 @typing.override가 가장 유용할 것이라고 생각합니다.
  • 구현에는 제대로 동작하지 않는 예외적인 경우가 있을 수 있습니다(그러한 비공개 소스 라이브러리 중 하나의 유지 관리자로부터 이것이 실제로 문제가 되어 왔다는 이야기를 들었습니다). 저희는 정적 강제가 단순하고 신뢰할 수 있을 것으로 예상합니다.
  • 저희가 알고 있는 구현 방식들은 단순하지 않습니다. 데코레이터는 클래스의 평가가 끝나기 전에 실행되므로, 저희가 알고 있는 선택지는 (@overrides.overrides가 하는 것처럼) 호출자의 바이트코드를 검사하거나 메타클래스 기반 접근 방식을 사용하는 것입니다. 어느 접근 방식도 이상적으로 보이지 않습니다.

베이스 클래스를 표시하여 서브클래스에 명시적 재정의 강제하기

저희는 클래스 데코레이터 @require_explicit_overrides를 포함하는 방안을 검토했습니다. 이 데코레이터가 있었다면 베이스 클래스는 모든 서브클래스가 메서드를 재정의할 때 @override 데코레이터를 사용해야 한다고 선언할 수 있었을 것입니다. Overrides 라이브러리에는 런타임 검사에서 비슷한 동작을 제공하는 믹스인 클래스 EnforceExplicitOverrides가 있습니다.

저희는 대규모 코드베이스의 소유자가 @override로부터 가장 큰 이점을 얻을 것으로 예상하며, 이러한 사용 사례에서는 베이스 클래스를 표시하는 방법보다 명시적 @override를 요구하는 엄격 모드(하위 호환성 절 참고)가 더 많은 이점을 제공하므로 이 방안을 채택하지 않았습니다.

게다가 저희는 추가적인 타입 안전성이 @override를 사용하는 데 드는 추가 상용구 코드만큼의 가치가 없다고 생각하는 프로젝트 작성자에게 이를 강제해서는 안 된다고 생각합니다. 선택적인 엄격 모드를 두면 결정권이 프로젝트 소유자에게 주어지지만, 라이브러리에서 @require_explicit_overrides를 사용하면 프로젝트 소유자가 원하지 않더라도 @override를 사용하도록 강제하게 됩니다.

재정의되는 조상 클래스의 이름 포함하기

저희는 @override의 호출자가 재정의되는 메서드가 정의되어 있어야 하는 특정 조상 클래스를 지정할 수 있도록 하는 방안을 검토했습니다:

class Parent0:
    def foo(self) -> int:
        return 1


class Parent1:
    def bar(self) -> int:
        return 1


class Child(Parent0, Parent1):
    @override(Parent0)  # okay, Parent0 defines foo
    def foo(self) -> int:
        return 2

    @override(Parent0)  # type error, Parent0 does not define bar
    def bar(self) -> int:
        return 2

이는 깊은 상속 트리에서 재정의 구조를 더 명시적으로 드러내므로 코드 가독성에 유용할 수 있습니다. 또한 재정의되는 메서드가 한 베이스 클래스에서 다른 베이스 클래스로 옮겨질 때마다 개발자가 재정의 구현이 여전히 타당한지 확인하도록 유도하여 버그를 잡아낼 수도 있습니다.

저희는 다음과 같은 이유로 이 방안을 채택하지 않았습니다:

  • 이를 지원하면 @override의 구현과 이에 대한 타입 검사기 지원이 모두 복잡해지므로, 그만큼 상당한 이점이 있어야 합니다.
  • 저희는 이 기능이 거의 사용되지 않고 잡아내는 버그도 비교적 적을 것이라고 생각합니다.
    • Overrides 패키지의 작성자는 자신의 라이브러리 초기 버전에 이 기능이 포함되어 있었지만 거의 유용하지 않았고 이점도 적어 보였다고 언급했습니다. 이 기능이 제거된 후 사용자가 이를 요청한 적은 한 번도 없었습니다.

참조 구현

Pyre: Pyre에 개념 증명이 구현되어 있습니다: