PEP 487 – 더 간단한 클래스 생성 사용자 정의
- Author:
- Martin Teichmann <lkb.teichmann at gmail.com>
- Status:
- Final
- Type:
- Standards Track
- Created:
- 27-Feb-2015
- Python-Version:
- 3.6
- Post-History:
- 27-Feb-2015, 05-Feb-2016, 24-Jun-2016, 02-Jul-2016, 13-Jul-2016
- Replaces:
- 422
- Resolution:
- Python-Dev message
번역·라이선스 안내
이 비공식 한국어 번역은 원문 Copyright 절의 Public Domain 조건에 따라 제공합니다. 원저자와 공식 원문은 그대로 표시합니다. 수정되지 않은 기준 원문 · 공식 최신판
초록
현재는 클래스 생성을 사용자 정의하려면 사용자 정의 메타클래스를 사용해야 합니다. 이렇게 만든 사용자 정의 메타클래스는 클래스의 생명 주기 내내 유지되므로 불필요한 메타클래스 충돌이 생길 수 있습니다.
이 PEP는 대신 클래스 본문의 새로운 __init_subclass__ 훅과 속성을 초기화하는 훅을 통해 폭넓은 사용자 정의 시나리오를 지원할 것을 제안합니다.
새로운 메커니즘은 사용자 정의 메타클래스를 구현하는 것보다 이해하고 사용하기 쉬워야 하며, 따라서 Python 메타클래스 기법의 강력한 기능 전체를 좀 더 완만하게 익힐 수 있게 해 주어야 합니다.
배경
메타클래스는 클래스 생성을 사용자 정의하는 강력한 도구입니다. 그러나 메타클래스를 자동으로 결합할 방법이 없다는 문제가 있습니다. 한 클래스에 두 메타클래스를 사용하려면 그 둘을 결합하는 새 메타클래스를 만들어야 하며, 보통은 이를 직접 만들어야 합니다.
사용자는 이런 필요성을 뜻밖에 맞닥뜨리는 경우가 많습니다. 서로 다른 두 라이브러리에서 온 두 베이스 클래스를 상속하면 갑자기 결합된 메타클래스를 직접 만들어야 하는데, 사용자는 보통 라이브러리의 그런 세부 사항에 전혀 관심이 없습니다. 어떤 라이브러리가 이전에는 사용하지 않던 메타클래스를 사용하기 시작하면 상황은 더욱 나빠집니다. 라이브러리 자체는 계속 완벽하게 동작하지만, 그 클래스를 다른 라이브러리의 클래스와 결합하는 모든 코드가 갑자기 실패합니다.
제안
메타클래스를 사용하는 방법은 여러 가지가 있지만, 사용 사례 대부분은 단 세 가지 범주에 속합니다. 클래스 생성 후에 실행되는 초기화 코드, 디스크립터의 초기화, 그리고 클래스 속성이 정의된 순서의 유지입니다.
처음 두 범주는 클래스 생성 과정에 간단한 훅을 두는 것으로 쉽게 구현할 수 있습니다.
- 주어진 클래스의 모든 서브클래스를 초기화하는
__init_subclass__훅 - 클래스 생성 시 클래스에 정의된 모든 속성(디스크립터)에 대해
__set_name__훅이 호출되며,
세 번째 범주는 다른 PEP인 PEP 520의 주제입니다.
예를 들어, 첫 번째 사용 사례는 다음과 같습니다:
>>> class QuestBase:
... # this is implicitly a @classmethod (see below for motivation)
... def __init_subclass__(cls, swallow, **kwargs):
... cls.swallow = swallow
... super().__init_subclass__(**kwargs)
>>> class Quest(QuestBase, swallow="african"):
... pass
>>> Quest.swallow
'african'
베이스 클래스 object에는 협력적 다중 상속의 종점 역할을 하는 빈 __init_subclass__ 메서드가 있습니다. 이 메서드에는 키워드 인자가 없으므로, 더 특수화된 모든 메서드가 모든 키워드 인자를 처리해야 한다는 점에 유의하십시오.
이 제안의 전반적인 내용은 새로운 아이디어가 아니지만(언어 정의에 포함하자는 제안이 more than 10 years ago 처음 나왔고, Zope’s ExtensionClass에서도 비슷한 메커니즘을 오랫동안 지원해 왔습니다), 최근 몇 년 사이에 상황이 충분히 바뀌었으므로 이 아이디어를 언어에 포함하는 방안을 다시 검토할 가치가 있습니다.
제안의 두 번째 부분은 클래스 속성, 특히 디스크립터인 클래스 속성을 위한 __set_name__ 초기화자를 추가합니다. 디스크립터는 클래스 본문에서 정의되지만 그 클래스에 대해 아무것도 모르며, 자신에게 접근할 때 쓰이는 이름조차 모릅니다. __get__이 호출되면 자신의 소유자는 알게 되지만, 여전히 자신의 이름은 모릅니다. 이는 아쉬운 점입니다. 예를 들어, 디스크립터는 자신의 이름을 모르기 때문에 연관된 값을 그 이름으로 객체의 __dict__에 넣을 수 없습니다. 이 문제는 여러 번 해결되어 왔으며, 라이브러리에 메타클래스를 두는 가장 중요한 이유 가운데 하나입니다. 제안의 첫 번째 부분을 사용하면 이런 메커니즘을 쉽게 구현할 수 있겠지만, 이 문제에 대해 모두가 함께 쓰는 해결책을 하나 두는 것이 합리적입니다.
사용 예를 들기 위해 약한 참조 값을 나타내는 디스크립터를 생각해 봅시다:
import weakref
class WeakAttribute:
def __get__(self, instance, owner):
return instance.__dict__[self.name]()
def __set__(self, instance, value):
instance.__dict__[self.name] = weakref.ref(value)
# this is the new initializer:
def __set_name__(self, owner, name):
self.name = name
예를 들어 이런 WeakAttribute는 부모를 통한 순환 참조를 피하려는 트리 구조에서 사용할 수 있습니다:
class TreeNode:
parent = WeakAttribute()
def __init__(self, parent):
self.parent = parent
parent 속성은 일반 속성처럼 사용되지만, 트리에는 순환 참조가 없으므로 더 이상 사용되지 않을 때 쉽게 가비지 컬렉션될 수 있다는 점에 유의하십시오. 부모가 더 이상 존재하지 않게 되면 parent 속성은 마법처럼 None이 됩니다.
이 예제는 매우 사소해 보이지만, 지금까지는 메타클래스를 사용하지 않고서는 이러한 속성을 정의할 수 없었다는 점에 주목해야 합니다. 그리고 그러한 메타클래스는 작업을 매우 어렵게 만들 수 있기 때문에, 이런 종류의 속성은 아직 존재하지 않습니다.
디스크립터 초기화는 간단히 __init_subclass__ 훅에서 할 수도 있습니다. 하지만 이는 적절한 훅이 있는 클래스에서만 디스크립터를 사용할 수 있다는 뜻이며, 예제와 같은 범용 버전은 일반적으로 동작하지 않을 것입니다. object.__init_subclass__의 기본 구현 내부에서 __set_name__을 호출할 수도 있습니다. 하지만 super() 호출을 잊는 것은 흔한 실수이므로, 갑자기 디스크립터가 초기화되지 않는 일이 너무 자주 발생할 것입니다.
주요 이점
정의 시점 동작의 더 쉬운 상속
Python의 메타클래스를 이해하려면 타입 시스템과 클래스 생성 과정을 깊이 이해해야 합니다. 여러 움직이는 부분(코드, 메타클래스 힌트, 실제 메타클래스, 클래스 객체, 클래스 객체의 인스턴스)을 머릿속에서 명확하게 구별해야 하기 때문에, 이는 당연히 어려운 일로 여겨집니다. 규칙을 알고 있더라도, 극도로 주의하지 않으면 여전히 실수하기 쉽습니다.
제안된 암시적 클래스 초기화 훅을 이해하는 데는 일반적인 메서드 상속만 알면 되며, 이는 그리 벅찬 일이 아닙니다. 새로운 훅은 클래스 정의 과정에 관련된 모든 단계를 더 점진적으로 이해해 나갈 수 있는 길을 제공합니다.
메타클래스 충돌 가능성 감소
라이브러리 작성자가 (적절한 경우에조차) 메타클래스 사용을 꺼리게 만드는 큰 문제 중 하나는 메타클래스 충돌의 위험입니다. 이러한 충돌은 클래스 정의에서 원하는 부모 클래스들이 서로 관련 없는 두 메타클래스를 사용할 때마다 발생합니다. 이 위험 때문에 이전에 메타클래스 없이 공개된 클래스에 메타클래스를 추가하는 것도 매우 어려워집니다.
반면, 기존 타입에 __init_subclass__ 메서드를 추가하는 것은 __init__ 메서드를 추가하는 것과 비슷한 수준의 위험을 수반합니다. 기술적으로는 잘못 구현된 서브클래스를 망가뜨릴 위험이 있지만, 그런 일이 발생하면 라이브러리 작성자가 하위 호환성 보장을 어긴 것이 아니라 서브클래스의 버그로 인식됩니다.
클래스를 사용하는 새로운 방법
서브클래스 등록
특히 플러그인 시스템을 작성할 때는 플러그인 베이스 클래스의 새 서브클래스를 등록하고 싶어집니다. 이는 다음과 같이 할 수 있습니다:
class PluginBase:
subclasses = []
def __init_subclass__(cls, **kwargs):
super().__init_subclass__(**kwargs)
cls.subclasses.append(cls)
이 예제에서 PluginBase.subclasses에는 전체 상속 트리에 있는 모든 서브클래스의 단순한 리스트가 담깁니다. 이 방식은 믹스인 클래스로도 잘 동작한다는 점에 유의하십시오.
트레이트 디스크립터
실제로 쓰이는 Python 디스크립터 설계 중에는, 예를 들어 값의 경계를 검사하는 것이 많이 있습니다. 이러한 “트레이트”는 동작하기 위해 메타클래스의 지원이 필요한 경우가 많습니다. 이 PEP를 사용하면 다음과 같은 모습이 됩니다:
class Trait:
def __init__(self, minimum, maximum):
self.minimum = minimum
self.maximum = maximum
def __get__(self, instance, owner):
return instance.__dict__[self.key]
def __set__(self, instance, value):
if self.minimum < value < self.maximum:
instance.__dict__[self.key] = value
else:
raise ValueError("value not in range")
def __set_name__(self, owner, name):
self.key = name
구현 세부 사항
훅은 다음 순서로 호출됩니다. type.__new__는 새 클래스가 초기화된 후에 디스크립터의 __set_name__ 훅을 호출합니다. 그런 다음 베이스 클래스, 정확히 말하면 super()의 __init_subclass__를 호출합니다. 즉, 서브클래스 초기화 함수는 이미 완전히 초기화된 디스크립터를 보게 됩니다. 이렇게 하면 __init_subclass__ 사용자는 필요할 경우 모든 디스크립터를 다시 수정할 수 있습니다.
또 다른 방법은 object.__init_subclass__의 기본 구현에서 __set_name__을 호출하는 것이었습니다. 이렇게 하면 __set_name__이 호출되는 것을 막는 것조차 가능해집니다. 그러나 super() 호출을 잊어버리는 일이 흔하므로, 대부분의 경우 그러한 방지는 의도하지 않은 것이 됩니다.
세 번째 방법으로, 모든 작업을 type.__init__에서 수행할 수도 있었습니다. 문서에서 권장하는 대로, 대부분의 메타클래스는 __new__에서 작업을 수행합니다. 많은 메타클래스는 인자를 super().__new__에 넘기기 전에 수정합니다. 이러한 종류의 클래스와 호환되려면 훅은 __new__에서 호출되어야 합니다.
작은 변경이 하나 더 필요합니다. 현재 CPython 구현에서 type.__init__은 키워드 인자 사용을 명시적으로 금지하는 반면, type.__new__는 속성을 키워드 인자로 전달하는 것을 허용합니다. 이는 이상할 정도로 일관성이 없으므로 금지되어야 합니다. 현재 동작을 유지할 수도 있지만, 아마 전혀 사용되지 않을 것이므로 수정하는 편이 더 낫습니다. 유일한 사용 사례는 메타클래스가 name, bases, dict(그렇습니다. 최신 메타클래스에서 네임스페이스를 가리킬 때 주로 쓰는 namespace나 ns가 아니라 dict입니다)를 키워드 인자로 전달하여 자신의 super().__new__를 호출하는 경우일 것입니다. 이렇게 해서는 안 됩니다. 이 작은 변경은 이 PEP의 구현을 크게 단순화하는 동시에 Python 전체의 일관성을 높입니다.
두 번째 변경으로, 새 type.__init__은 키워드 인자를 그냥 무시합니다. 현재는 키워드 인자가 주어지지 않아야 한다고 고집합니다. 이 때문에 메타클래스가 키워드 인자를 처리하지 않는데 클래스 선언에 키워드 인자를 주면 (의도된) 에러가 발생합니다. 키워드 인자를 받고자 하는 메타클래스 작성자는 __init__을 재정의하여 키워드 인자를 걸러내야 합니다.
새 코드에서는 키워드 인자에 대해 불평하는 것이 __init__이 아니라 __init_subclass__이며, 그 기본 구현은 인자를 받지 않습니다. 메서드 결정 순서를 사용하는 고전적인 상속 체계에서, 각 __init_subclass__는 남은 키워드 인자가 없어질 때까지 자신의 키워드 인자를 꺼내 갈 수 있으며, 이는 __init_subclass__의 기본 구현이 검사합니다.
영어보다 Python 코드를 읽는 편을 선호하는 독자를 위해 말씀드리면, 이 PEP는 현재의 type과 object를 다음으로 대체할 것을 제안합니다.:
class NewType(type):
def __new__(cls, *args, **kwargs):
if len(args) != 3:
return super().__new__(cls, *args)
name, bases, ns = args
init = ns.get('__init_subclass__')
if isinstance(init, types.FunctionType):
ns['__init_subclass__'] = classmethod(init)
self = super().__new__(cls, name, bases, ns)
for k, v in self.__dict__.items():
func = getattr(v, '__set_name__', None)
if func is not None:
func(self, k)
super(self, self).__init_subclass__(**kwargs)
return self
def __init__(self, name, bases, ns, **kwargs):
super().__init__(name, bases, ns)
class NewObject(object):
@classmethod
def __init_subclass__(cls):
pass
참조 구현
이 PEP의 참조 구현은 issue 27366에 첨부되어 있습니다.
하위 호환성 문제
type.__new__의 정확한 호출 순서가 약간 바뀌어 하위 호환성에 대한 우려가 제기됩니다. 일반적인 사용 사례가 원하는 대로 동작하는지 테스트로 보장해야 합니다.
다음 클래스 정의들은 (메타클래스를 정의하는 것을 제외하고) 불필요한 클래스 인자가 전달되므로 계속해서 TypeError로 실패합니다:
class MyMeta(type):
pass
class MyClass(metaclass=MyMeta, otherarg=1):
pass
MyMeta("MyClass", (), otherargs=1)
import types
types.new_class("MyClass", (), dict(metaclass=MyMeta, otherarg=1))
types.prepare_class("MyClass", (), dict(metaclass=MyMeta, otherarg=1))
기본 type.__init__이 키워드 인자를 무시하므로, 키워드 인자를 사용하면서 __new__ 메서드만 정의하는 메타클래스는 이제 더 이상 __init__ 메서드를 정의할 필요가 없습니다. 이는 메타클래스에서 __init__ 대신 __new__를 재정의하라는 권장 사항과 잘 맞아떨어집니다. 다음 코드는 더 이상 실패하지 않습니다:
class MyMeta(type):
def __new__(cls, name, bases, namespace, otherarg):
return super().__new__(cls, name, bases, namespace)
class MyClass(metaclass=MyMeta, otherarg=1):
pass
메타클래스에 __init__ 메서드만 정의한 경우, 키워드 인자가 주어지면 이전과 마찬가지로 TypeError로 실패합니다.:
class MyMeta(type):
def __init__(self, name, bases, namespace, otherarg):
super().__init__(name, bases, namespace)
class MyClass(metaclass=MyMeta, otherarg=1):
pass
__init__과 __new__를 모두 정의하는 경우는 이전과 마찬가지로 잘 작동합니다.
작동하지 않게 되는 것은 사실상 type.__new__의 인자를 키워드 인자로 전달하는 경우뿐입니다.:
class MyMeta(type):
def __new__(cls, name, bases, namespace):
return super().__new__(cls, name=name, bases=bases,
dict=namespace)
class MyClass(metaclass=MyMeta):
pass
이제 이 경우 TypeError가 발생하지만, 이는 이상한 코드이며 누군가 이 기능을 사용했더라도 쉽게 고칠 수 있습니다.
기각된 설계 방안
클래스 자체에서 훅 호출하기
클래스 자체에서도 호출되는 __autodecorate__ 훅을 추가하는 것이 PEP 422에서 제안된 아이디어였습니다. 대부분의 예제는 훅이 엄격한(strict) 서브클래스에서만 호출되더라도 똑같이, 혹은 더 잘 작동합니다. 일반적으로, 훅이 정의된 클래스에서 훅을 명시적으로 호출하도록 구성하는 것(그러한 동작을 선택하는 것)이 선택하지 않는 것(훅 본문에서 cls is __class인지 확인하는 것을 기억하는 방식)보다 훨씬 쉽습니다. 이는 곧 훅이 정의된 클래스에서는 훅이 호출되기를 원하지 않는다는 뜻입니다.
이는 해당 클래스가 믹스인으로 설계된 경우에 가장 분명해집니다. 믹스인은 그 자체로 완전한 클래스가 되도록 의도된 것이 아니므로, 믹스인의 코드가 믹스인 클래스 자체에 대해 실행될 가능성은 매우 낮습니다.
또한 원래의 제안은 클래스 초기화 과정에 큰 변경을 가했기 때문에, 이 제안을 이전 버전의 Python으로 백포트하는 것이 불가능했습니다.
베이스 클래스에서도 훅을 호출하기를 원하는 경우, 두 가지 메커니즘을 사용할 수 있습니다:
__init_subclass__구현만을 담기 위한 추가 믹스인 클래스를 도입합니다. 그러면 원래의 “베이스” 클래스는 새 믹스인을 첫 번째 부모 클래스로 나열할 수 있습니다.- 원하는 동작을 독립적인 클래스 데코레이터로 구현하고, 그 데코레이터를 베이스 클래스에는 명시적으로 적용한 다음, 서브클래스에는
__init_subclass__를 통해 암묵적으로 적용합니다.
클래스 데코레이터에서 __init_subclass__를 명시적으로 호출하는 것은 일반적으로 바람직하지 않습니다. 이렇게 하면 보통 부모 클래스에서도 __init_subclass__가 두 번째로 호출되는데, 이는 원하는 동작이 아닐 가능성이 높기 때문입니다.
훅을 호출하는 다른 방식들
훅의 다른 이름으로 __decorate__ 또는 __autodecorate__가 제시되었습니다. 이 제안은 __init_subclass__를 선택합니다. 이 훅은 서브클래스를 위한 것이라는 점만 다를 뿐 __init__ 메서드와 매우 비슷하며, 클래스를 반환하지 않으므로 데코레이터와는 그다지 비슷하지 않기 때문입니다.
__set_name__ 훅에 대해서도 __set_owner__, __set_ownership__, __init_descriptor__ 같은 다른 이름들이 제안되었습니다.
__init_subclass__에 명시적인 데코레이터 요구하기
__init_subclass__ 데코레이터에 @classmethod를 명시적으로 사용하도록 요구할 수도 있습니다. 이를 생략하는 경우에 대한 합리적인 해석이 없고, 유용한 오류 메시지를 제공하려면 어차피 그 경우를 감지해야 하므로 암묵적으로 처리하도록 했습니다.
이 결정은 __prepare__를 정의하면서 @classmethod 메서드 데코레이터를 빠뜨렸을 때의 사용자 경험이 유달리 이해하기 어렵다는 점을 알게 된 후 더욱 굳어졌습니다(특히 PEP 3115가 이를 일반 메서드로 문서화하고 있고, 현재 문서에서도 어느 쪽인지 명시적으로 언급하지 않기 때문입니다).
좀 더 __new__와 비슷한 훅
PEP 422에서 훅은 __init__ 메서드보다는 __new__ 메서드에 가깝게 작동했습니다. 즉, 클래스를 수정하는 대신 클래스를 반환했습니다. 이 방식은 유연성을 조금 더 제공하지만, 구현이 훨씬 어려워지고 원하지 않는 부작용이 생긴다는 대가가 따릅니다.
속성 순서를 담은 클래스 속성 추가하기
이는 별도의 PEP 520으로 다루어졌습니다.
역사
이 제안은 원래 Alyssa Coghlan과 Daniel Urban이 작성한 PEP 422에 대한 경쟁 제안이었습니다. PEP 422는 이 PEP와 같은 목표를 달성하려 했지만, 구현 방식이 달랐습니다. 그 사이 PEP 422는 이 접근 방식을 지지하며 철회되었습니다.
참고 문헌
Copyright
This document has been placed in the public domain.