Home [Better Way #48] __init_subclass__를 사용해 하위 클래스를 검증하라
Post
Cancel

[Better Way #48] __init_subclass__를 사용해 하위 클래스를 검증하라

본문은 “파이썬 코딩의 기술 (Effective Python, 2판)”“Chapter 06. Metaclasses and Attributes”을 읽고 정리한 내용입니다.


1. 메타클래스의 가장 간단한 활용법

메타클래스의 대표적인 사용 목적은 어떤 클래스(하위 클래스)가 제대로 구현됐는지 검증하는 것이다.

  • 복잡한 클래스 계층을 설계할 때, 어떤 스타일을 강제로 지키도록 할 때
  • 메서드를 오버라이드하도록 요청할 때
  • 클래스 애트리뷰트 사이에 엄격한 관계를 가지도록 할 때

이처럼 검증에 메타클래스를 사용하면, 프로그램 시작 시 클래스가 정의된 모듈을 처음 import 할 때와 같은 시점에 검증이 이루어지므로 예외가 훨씬 빨리 발생할 수 있다.


2. 일반적인 객체에 대해 메타클래스가 작동하는 방법

메타클래스는 type을 상속해 정의되며, 기본적으로 __new__ 메서드를 통해 자신과 연관된 클래스의 내용을 받는다.

메타클래스의 __new__ 메서드

class 문의 모든 본문이 처리된 직후에 호출된다.

[Python] 메타클래스(Metaclass) 포스팅 참고

  • 메타클래스 정의

    1
    2
    3
    4
    5
    6
    
    class Meta(type):
        def __new__(meta, name, bases, class_dict):
            print(f"* 실행: {name}의 metaclass {meta}.__new__")
            print("부모 클래스들:", bases)
            print(class_dict)
            return type.__new__(meta, name, bases, class_dict)
    
  • 메타클래스를 지정한 클래스

    1
    2
    3
    4
    5
    
    class MyClass(metaclass=Meta):
        stuff = 123
          
        def foo(self):
            pass
    
    1
    2
    3
    
    * 실행: MyClass의 metaclass <class '__main__.Meta'>.__new__
    부모 클래스들: ()
    {'__module__': '__main__', '__qualname__': 'MyClass', 'stuff': 123, 'foo': <function MyClass.foo at 0x106f05a20>}
    
  • 메타클래스를 지정한 클래스를 상속하는 클래스

    1
    2
    3
    4
    5
    
    class MySubclass(MyClass):
        other = 567
          
        def bar(self):
            pass
    
    1
    2
    3
    
    * 실행: MySubclass의 metaclass <class '__main__.Meta'>.__new__
    부모 클래스들: (<class '__main__.MyClass'>,)
    {'__module__': '__main__', '__qualname__': 'MySubclass', 'other': 567, 'bar': <function MySubclass.bar at 0x106f05b40>}
    


위의 코드를 통해 메타클래스는 다음과 같은 요소에 접근할 수 있음을 알 수 있다.

  • meta: 클래스 이름
  • bases: 클래스가 상속하는 부모 클래스들

    모든 클래스가 상속하는 object는 명시적으로 들어있지 않다.

  • class_dict: class 본문에 정의된 모든 클래스 애트리뷰트

    해당 클래스가 상속하는 부모 클래스의 본문에 정의된 클래스 애트리뷰트는 포함하지 않는다.


3. 연관된 클래스 정의 전, 파라미터 검증하기

[1] 메타클래스

다각형을 표현하는 타입을 만든다고 가정하자.

검증을 수행하는 특별한 메타클래스를 정의하고, Meta.__new__에 파라미터 검증 기능을 추가한다.

이 메타클래스를 모든 다각형 클래스 계층 구조의 기반 클래스로 사용한다.

  • 메타클래스 (변의 개수 검증)

    1
    2
    3
    4
    5
    6
    7
    
    class ValidationPolygon(type):
        def __new__(meta, name, bases, class_dict):
            # Polygon 클래스의 하위 클래스만 검증한다
            if bases:
                if class_dict["sides"] < 3:
                    raise ValueError("다각형 변은 3개 이상이어야 함")
            return type.__new__(meta, name, bases, class_dict)
    
  • 다각형 클래스

    1
    2
    3
    4
    5
    6
    
    class Polygon(metaclass=ValidationPolygon):
        sides = None  # 하위 클래스는 이 애트리뷰트에 값을 지정해야 한다
      
        @classmethod
        def interior_angles(cls):
            return (cls.sides - 2) * 180
    


변의 개수가 3개 이상인 다각형 클래스를 선언하면 오류 없이 사용할 수 있다.

1
2
3
4
5
6
7
8
9
10
11
12
class Triangle(Polygon):
    sides = 3

class Rectangle(Polygon):
    sides = 4

class Nonagon(Polygon):
    sides = 9

assert Triangle.interior_angles() == 180
assert Rectangle.interior_angles() == 360
assert Nonagon.interior_angles() == 1260

하지만, 변의 개수가 2개 이하인 클래스를 정의하면, class 본문이 처리된 직후 에러가 발생한다.

1
2
3
4
5
6
7
8
# 변이 두 개 이하인 클래스를 정의하면 프로그램이 아예 시작하지도 않음

print("class 이전")

class Line(Polygon):
    sides = 2  # sides에 3 미만 값을 설정

print("class 이후")
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class 이전
---------------------------------------------------------------------------
ValueError                                Traceback (most recent call last)
Cell In[12], line 5
      1 # 변이 두 개 이하인 클래스를 정의하면 프로그램이 아예 시작하지도 않음
      3 print('class 이전')
----> 5 class Line(Polygon):
      6     sides = 2   # sides에 3 미만 값을 설정
      8 print('class 이후')

Cell In[7], line 6
      4 if bases:
      5     if class_dict["sides"] < 3:
----> 6         raise ValueError("다각형 변은 3개 이상이어야 함")
      7 return type.__new__(meta, name, bases, class_dict)

ValueError: 다각형 변은 3개 이상이어야 함


[2] __init_subclass__ 메서드

위와 같은 다각형의 변 개수를 검증하는 기능을 다각형 클래스의 메타클래스 ValidatePolygon이 아닌 __init_subclass__ 메서드를 통해서 구현할 수도 있다.

이러한 방식의 장점은 다음과 같은 것들이 있다.

  • 코드가 훨씬 짧아진다.
  • 메타클래스를 작성할 필요가 없다.
  • 메타클래스에서 클래스 애트리뷰트 sides에 접근하기 위해서는 class_dict["sides"]와 같이 접근해야 했으나, __init_subclass__ 메서드에서는 cls.sides로 접근할 수 있다.


__init_subclass__ 메서드에 검증 기능을 추가한 BetterPolygon 클래스를 정의한다.

1
2
3
4
5
6
7
8
9
10
11
class BetterPolygon:
    sides = None  # 하위 클래스에서 이 애트리뷰트의 값을 지정해야 함

    def __init_subclass__(cls):
        super().__init_subclass__()
        if cls.sides < 3:
            raise ValueError("다각형 변은 3개 이상이어야 함")

    @classmethod
    def interior_angles(cls):
        return (cls.sides - 2) * 180


모든 코드가 메타클래스를 이용한 방법에서와 동일하게 동작한다.

변의 개수가 3개 이상인 다각형 클래스를 선언하면 오류 없이 사용할 수 있다.

1
2
3
4
class Hexagon(BetterPolygon):
    sides = 6

assert Hexagon.interior_angles() == 720

하지만, 변의 개수가 2개 이하인 클래스를 정의하면, class 본문이 처리된 직후 에러가 발생한다.

1
2
3
4
5
6
print("class 이전")

class Point(BetterPolygon):
    sides = 1

print("class 이후")
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class 이전
---------------------------------------------------------------------------
ValueError                                Traceback (most recent call last)
Cell In[15], line 3
      1 print('class 이전')
----> 3 class Point(BetterPolygon):
      4     sides = 1
      6 print('class 이후')

Cell In[13], line 7
      5 super().__init_subclass__()
      6 if cls.sides < 3:
----> 7     raise ValueError('다각형 변은 3개 이상이어야 함')

ValueError: 다각형 변은 3개 이상이어야 함


클래스를 정의할 때, 파라미터를 검증하기 위해 메타클래스를 이용하는 방법 또는 __init_subclass__ 메서드를 이용하는 방법을 적용할 수 있다는 것을 알아보았다.

하지만 검증해야 할 요소가 여러 개라면? 이러한 경우에는 어떤 방법이 더 유리할까?


4. 검증 요소 여러 개 추가하기

[1] 메타클래스

메타클래스를 이용한 검증 방식에서는 검증 요소를 한 번에 여러 개 추가할 수 없다. 왜냐하면 클래스 정의마다 메타클래스는 단 하나만 지정할 수 있기 때문이다.


다음의 코드에서는 FilledValidateFilled 메타클래스를, PolygonValidationPolygon 메타클래스를 지정하고 있으므로 두 메타클래스가 충돌하여 에러가 발생한다.

1
2
3
4
5
6
7
8
9
10
class ValidateFilled(type):
    def __new__(meta, name, bases, class_dict):
        # Filled 클래스의 하위 클래스만 검증한다
        if bases:
            if class_dict["color"] not in ("red", "green"):
                raise ValueError("지원하지 않는 color 값")
        return type.__new__(meta, name, bases, class_dict)

class Filled(metaclass=ValidateFilled):
    color = None  # 모든 하위 클래스에서 이 애트리뷰트의 값을 지정해야 한다
1
2
3
class RedPentagon(Filled, Polygon):
    color = "red"
    sides = 5
1
2
3
4
5
6
7
8
---------------------------------------------------------------------------
TypeError                                 Traceback (most recent call last)
Cell In[17], line 1
----> 1 class RedPentagon(Filled, Polygon):
      2     color = 'red'
      3     sides = 5

TypeError: metaclass conflict: the metaclass of a derived class must be a (non-strict) subclass of the metaclasses of all its bases


따라서 메타클래스를 이용하여 검증을 여러 단계로 만들기 위해서는 type 정의를 복잡한 계층으로 설계해야 한다. ValidatePolygon을 메타클래스로 가지는 Polygon 클래스를 상속하고, ValidateFilledPolygon을 메타클래스로 가지는 FilledPolygon을 상속하여 만들면 된다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
class ValidatePolygon(type):
    def __new__(meta, name, bases, class_dict):
        # 루트 클래스가 아닌 경우에만 검증
        if not class_dict.get("is_root"):
            if class_dict["sides"] < 3:
                raise ValueError("다각형 변은 3개 이상이어야 함")
        return type.__new__(meta, name, bases, class_dict)


# NOTE: is_root는 Polygon과 FilledPolygon이 상속되지 않고 그냥 쓰일 때에만 class_dict.get()으로 접근 가능
class Polygon(metaclass=ValidatePolygon):
    is_root = True
    sides = None  # 하위 클래스에서 지정해주어야 함
   
    
class ValidateFilledPolygon(ValidatePolygon):
    def __new__(meta, name, bases, class_dict):
        # 루트 클래스가 아닌 경우에만 검증
        if not class_dict.get("is_root"):
            if class_dict["color"] not in ("red", "green"):
                raise ValueError("지원하지 않는 color 값")
        return super().__new__(meta, name, bases, class_dict)
1
2
3
class FilledPolygon(Polygon, metaclass=ValidateFilledPolygon):
    is_root = True
    color = None  # 하위 클래스에서 지정해주어야 함
  • 검증 통과한 경우

    1
    2
    3
    4
    5
    6
    
    class GreenPentagon(FilledPolygon):
        color = "green"
        sides = 5
      
    greenie = GreenPentagon()
    assert isinstance(greenie, Polygon)
    
  • 색 검증에 실패한 경우

    1
    2
    3
    
    class OrangePentagon(FilledPolygon):
        color = "orange"
        sides = 5
    
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    
    ---------------------------------------------------------------------------
    ValueError                                Traceback (most recent call last)
    Cell In[26], line 2
          1 # 색 검증 확인하기
    ----> 2 class OrangePentagon(FilledPolygon):
          3     color = "orange"
          4     sides = 5
      
    Cell In[15], line 15
          13 if not class_dict.get("is_root"):
          14     if class_dict["color"] not in ("red", "green"):
    ---> 15         raise ValueError("지원하지 않는 color 값")
          16 return super().__new__(meta, name, bases, class_dict)
      
    ValueError: 지원하지 않는 color 값
    
  • 변 개수 검증에 실패한 경우

    1
    2
    3
    
    class RedLine(FilledPolygon):
        color = "red"
        sides = 2
    
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    
    ---------------------------------------------------------------------------
    ValueError                                Traceback (most recent call last)
    Cell In[27], line 2
          1 # 변 개수 검증 확인하기
    ----> 2 class RedLine(FilledPolygon):
          3     color = "red"
          4     sides = 2
      
    Cell In[15], line 16
          14     if class_dict["color"] not in ("red", "green"):
          15         raise ValueError("지원하지 않는 color 값")
    ---> 16 return super().__new__(meta, name, bases, class_dict)
      
    Cell In[15], line 6
          4 if not class_dict.get("is_root"):
          5     if class_dict["sides"] < 3:
    ----> 6         raise ValueError("다각형 변은 3개 이상이어야 함")
          7 return type.__new__(meta, name, bases, class_dict)
      
    ValueError: 다각형 변은 3개 이상이어야 함
    


이처럼 메타클래스를 이용하여 여러 검증 요소를 추가하면 다음과 같은 단점이 있다.

  • 모든 로직을 중복 정의해야 하므로 코드 재사용이 줄어든다.
  • 불필요한 준비 코드가 늘어난다.
  • 합성성이 저해된다.


[2] __init_subclass__ 메서드

하지만 __init_subclass__를 사용하여 여러 검증 요소를 추가하면 super 내장 함수를 이용해 부모나 형제자매 클래스의 __init_subclass__를 호출해주는 한, 여러 단계로 이루어진 검증 구조를 쉽게 정의할 수 있다. 또한, 이는 다중 상속과도 잘 어우러진다.

__init_subclass__ 정의 안에서 super().__init_subclass__를 호출해 여러 계층에 걸쳐 클래스를 검증하고 다중 상속을 제대로 처리할 수 있다.


다중 상속 __init_subclass__ 메서드에서 변의 개수를 검증했던 BetterPolygon 클래스와, 다음과 같이 __init_subclass__ 메서드에서 색을 검증하는 Filled 클래스를 모두 상속해보자. 두 클래스 BetterPolygonFilled는 모두 super().__init_subclass__()를 호출하므로 하위 클래스가 생성될 때 각각의 검증 로직이 실행된다.

1
2
3
4
5
6
7
class Filled:
    color = None  # 하위 클래스에서 이 애트리뷰트 값을 지정해야 함

    def __init_subclass__(cls):
        super().__init_subclass__()
        if cls.color not in ("red", "green", "blue"):
            raise ValueError("지원하지 않는 color 값")
  • 검증 통과한 경우

    1
    2
    3
    4
    5
    6
    7
    
    class RedTriangle(Filled, BetterPolygon):
        color = "red"
        sides = 3
      
    ruddy = RedTriangle()
    assert isinstance(ruddy, Filled)
    assert isinstance(ruddy, BetterPolygon)
    
  • 변 개수 검증에 실패한 경우

    1
    2
    3
    4
    5
    6
    7
    
    print("class 이전")
      
    class BlueLine(Filled, Polygon):
        color = "blue"
        sides = 2
      
    print("class 이후")
    
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    
    class 이전
    ---------------------------------------------------------------------------
    ValueError                                Traceback (most recent call last)
    Cell In[32], line 5
          1 # 변의 수를 잘못 지정한 경우
          3 print("class 이전")
    ----> 5 class BlueLine(Filled, Polygon):
          6     color = "blue"
          7     sides = 2
      
    Cell In[15], line 6
          4 if not class_dict.get("is_root"):
          5     if class_dict["sides"] < 3:
    ----> 6         raise ValueError("다각형 변은 3개 이상이어야 함")
          7 return type.__new__(meta, name, bases, class_dict)
      
    ValueError: 다각형 변은 3개 이상이어야 함
    
  • 색 검증에 실패한 경우

    1
    2
    3
    4
    5
    6
    7
    
    print("class 이전")
      
    class BeigeSquare(Filled, Polygon):
        color = "beige"
        sides = 4
      
    print("class 이후")
    
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    
    class 이전
    ---------------------------------------------------------------------------
    ValueError                                Traceback (most recent call last)
    Cell In[33], line 6
          1 # 변의 수를 잘못 지정한 경우
          3 print("class 이전")
    ----> 6 class BeigeSquare(Filled, Polygon):
          7     color = "beige"
          8     sides = 4
      
    Cell In[15], line 7
          5     if class_dict["sides"] < 3:
          6         raise ValueError("다각형 변은 3개 이상이어야 함")
    ----> 7 return type.__new__(meta, name, bases, class_dict)
      
    Cell In[28], line 7
          5 super().__init_subclass__()
          6 if cls.color not in ('red', 'green', 'blue'):
    ----> 7     raise ValueError("지원하지 않는 color 값")
      
    ValueError: 지원하지 않는 color 값
    


다이아몬드 상속 다음의 코드에서 Bottom 클래스 → Top 클래스에 이르는 상속 경로가 두 가지(Left를 통하는 경우, Right를 통하는 경로)이지만, 각 클래스마다 Top.__init_subclass__는 단 한 번만 호출된다.

다이아몬드 상속의 경우, [Better Way #40] super로 부모 클래스를 초기화하라 (+ MRO) 포스팅을 참고한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class Top:
    def __init_subclass__(cls):
        super().__init_subclass__()
        print(f"{cls}의 Top")

class Left(Top):
    def __init_subclass__(cls):
        super().__init_subclass__()
        print(f"{cls}의 Left")

class Right(Top):
    def __init_subclass__(cls):
        super().__init_subclass__()
        print(f"{cls}의 Right")

class Bottom(Left, Right):
    def __init_subclass__(cls):
        super().__init_subclass__()
        print(f"{cls}의 Bottom")
1
2
3
4
5
<class '__main__.Left'>의 Top
<class '__main__.Right'>의 Top
<class '__main__.Bottom'>의 Top
<class '__main__.Bottom'>의 Right
<class '__main__.Bottom'>의 Left
This post is licensed under CC BY 4.0 by the author.

[Python] Bitwise & 연산을 이용하여 여러 Dictionary 간 교집합 구하기

[Better Way #49] __init_subclass__를 사용해 클래스 확장을 등록하라