IT/언어

[python] 다중상속

개발자 두더지 2021. 5. 28. 00:58
728x90

다중상속이란?


 "다중상속"이란 "여러 개의 클래스로부터 기능을 상속받는 것"을 의미한다. 예를 들어, "A클래스와 B클래스를 바탕으로 C클래스를 만든다"는 것이라고 할 수 있다. 참고로 "A클래스로부터 B클래스를 파생시켜, 다시 B클래스로부터 C클래스를 파생시키는 것"은 상속(단일 상속)의 연쇄일뿐이므로, 다중상속이라고 할 수 없다.

 다양한 이유로 인해 최근의 프로그래밍언어에서는 다중상속을 크게 지원하지 않지만, Python은 다중상속을 지원하고 있다. 다중상속을 구현하기 위해서는 클래스를 정의하고 기초가 되는 클래스를 여러 개 작성할 뿐이므로 작성법 자체는 어렵지 않다.

 아래를 코드를 예로 살펴보도록 하겠다.

class A:
    def hello(self):
        print('Hello from A')

class B(A):
    pass

class C(A):
    def hello(self):
        print('Hello from C')

class D(B, C):
    pass

 이 예에서는 A클래스를 대전제의 클래스로써 정의되어 있다(실제로는 그 부모 클래스로 object 클래스가 있지만). 그리고 그로부터 직접 파생된 클래스로 B클래스와 C클래스를 정의한 뒤, 그러한 2개의 클래스를 바탕으로 D 클래스를 정의하고 있다. 또한 A 클래스와 C클래스에서는 hello 메소드를 정의하고 있지만, 나머지 두 개의 클래스에서는 정의하고 있지않다.

 이 D클래스의 정의에서는 "class(B, C)"로 기저 클래스를 콤마로 구분하여 여러 개를 지정하고 있다. 이로 인해 다중상속이 실행되어, D클래스는 B클래스와 C클래스를 상속받게 된다.

 

 

어떤 클래스가 호출될 것인가?


 위와 같이 정의되어 있는 D클래스를 사용할 때는 생각해야할 문제가 있다. 그것은 "D클래스의 인스턴스에 대해 hello메소드를 호출하면, A클래스와 C클래스 중 어떤 hello메소드가 호출되는가"에 관련된 문제이다.

 메소드를 호출할 때에는 "어떤 메소드가 호출되는가 특정하는 것"을 "method resolution"이라고 부르기도 한다. 그리고 이 경우, 메소드를 resolution 하기위해서는 "D > B > A (> C > A )"이라는 순서로 hello메소드를 찾아서 최종적으로는 A클래스의 hello메소드가 resolution되는 경로와 "D > B > C (> A )"이라는 순서로 hello메소드를 찾아서 최종적으로 C클래스의 메소드가 resolution되는 경로 두 가지가 있다. 이렇게 메소드를 resolution하는 순서를 "MRO(Method Resolution Order)"이라고 부른다. 또한, 위 경로 중 괄호로 감싸져 있는 부분은 그 직전에 hello 메소드가 발견되므로 실제로 검색이 이루어지지 않기 때문이 괄호로 표시하였다.

 그럼, 실제로 어떤 메소드가 실행되는지 다음의 코드로 실행해보자.

d = D()
d.hello()

 실행 결과를 보면 C클래스의 메소드가 호출되어있다. 이 경우에는 "C > B > C > A"이라는 순서로 메소드가 검색되어 있지만, 이 순서는 Python3(및 Python2.3이후의 버전)에서 "C3선형화"이라고 불리는 알고리즘이 이용되어 resolution되어, 메소드의 검색 중에 어떠한 클래스(이 경우에는 A클래스)가 몇 번이라고 등장하지 않도록 되어있다.

 여기에서는 A~D클래스의 상속단계가 다음과 같이 되어있다.

 이와 같이 마름모꼴 (다이아몬드형)으로 상속 단계가 구성되어 있을 때에, 어떤 메소드가 호출되는가이나 같은 클래스 (이 경우에는 A클래스)가 메소드가 resolution될 때에 몇 번이라도 등장하는 것이 문제가 된다. 이것을 "다이아몬드 상속 문제" 혹은 "마름모형 상속 문제"라고 부르기도 한다. 

 또한, 아까 설명했지만, Python에서는 "깊이 우선"도 "폭 우선"도 아닌 "C3선형화"라고 불리는 알고리즘으로 어떤 메소드를 호출할 것인가를 결정하고 있다. 이전의 코드를 살짝 변경하여 이러한 알고리즘을 확인해 보자.

class A:
    def hello(self):
        print('Hello from A')

class B(A):
    pass

class C:
    def hello(self):
        print('Hello from C')

class D(B, C):
    pass

 이번에는 C클래스는 A 클래스의 파생 클래스가 아닌, object클래스를 상속받고 있다. 상속 단계를 이미지로 표시하면 다음과 같다. 

 이것으로 D클래스의 인스턴스를 작성하여, hello메소드를 호출해보자.

d = D()
d.hello()

 코드의 실행 결과는 다음과 같다.

 이번에는 A클래스의 hello 메소드가 호출됐다. 이것은 "D > B > A > C"의 순서로 메소드가 검색됐기 때문이다 (A에서 hello메소드가 발견되어). 맨 처음의 예에서는 "폭 우선"(연관된 기저 클래스를 우선)처럼 보였지만, 이번에는 "깊이 우선" (처음에 검색한 클래스의 계승 단계를 우선)을 하는 것처럼 보인다. 따라서 Python에 있어서 C3선형화에 의한 메소드 검색이 어떤 쪽에서든 적용되지 않는 것을 알 수 있다.

 이번에는 위의 코드에서 D클래스의 정의 부분의 B와 C의 순서를 변경해보자.

class A:
    def hello(self):
        print('Hello from A')

class B(A):
    pass

class C:
    def hello(self):
        print('Hello from C')

class D(C, B):
    pass

 D클래스는 C클래스와 B클래스를 상속받고 있다. 이 다음에는 동일한 코드로 실행 결과를 확인해보자.

d = D()
d.hello()

 그러면 결과는 다음과 같다. 이번에는 (대부분의 사람들이 예상했듯) C클래스의 hello 메소드가 호출된다.

 

 

MRO: 메소드 해결 순서(Method Resolution Order)


 이처럼 클래스 정의시의 기저 클래스를 다르게 지정하는 것만으로도 어떤 메소드가 호출되는가가 바뀐다. C3선형화 알고리즘을 정확히 이해하여, 이 순서를 충분히 익힌다면, 다중 상속은 어렵지 않게 사용할 수 있다. 그렇지만, 이러한 점에서 Python이 귀찮다고 생각할 수 있다. 실제로는 클래스에는 "__mro__"이라는 특수 속성이 있어, 이것을 이용하면 메소드를 resolution할 때에는 기저 클래스가 어떻게 검색해나가는지 순서를 알 수 있게 되어 있다. "mro"는 위에서 언급했듯이 "Metho resolution Order"의 생략단어이다. 또한, MRO는 다중 상속에 한해서가 아닌 단일 상속의 상속 단계에서도 사용된다.

 실제로 한 번 사용해보자. D클래스의 MRO를 표시하는 코드는 다음과 같다. 

print(D.__mro__)

 실행하면 결과는 다음과 같이 출력된다.

 결과로부터 메소드가  " D > C > B > A > Object" 라는 순서로 검색되고 있다는 것을 알 수 있다. Object클래스는 모든 클래스의 기저 클래스이므로, 최종적으로 검색되는 클래스가 된다.

 이와 같이, Object클래스가 전체의 기저 클래스가 되므로, 실제로는 위에서 나타나있듯 A클래스를 상속받지 않는 경우에도 클래스의 다중상속은 다이아몬드 형태가 된다. 그러므로 위에서 봤던 문제가 Python3에서 항상 발생한다.

 아래에서는 Object 클래스를 다이아몬드의 꼭지점으로 하여, 그것을 상속받는 B와 C 2개의 클래스, 더욱이 이 2개의 클래스를 상속받는 D클래스를 정의해보았다. 그러나, "class D(C, B)"와 기저 클래스의 리스트에서는 C클래스가 먼저 작성되어 있음을 주의하자.

class B:
    def __init__(self):
        self.b_value = 'B'
        print('class B init')

class C:
    def __init__(self):
        self.c_value = 'C'
        print('class C init')

class D(C, B):
    pass

 여기서 D 클래스의 인스턴스를 생성한 후, __mro__를 이용해 mro를 알아보자.

d = D()
print(D.__mro__)

 실행 결과를 보면 다음과 같다.

 이 경우세는 C클래스의 __init__메소드가 호출된다 ("클래스 D(C,B)"로 정의되어 있으므로, MRO에서는 C가 우선적으로 검색되기 때문에) . 한 편 이 출력으로부터 B클래스의 __init__메소드가 호출되지 않는 것을 알 수 있다. 즉, D클래스는 C클래스와 B클래스를 상속하고 있지만, B클래스의 __init__메소드로 정의되어 있어야할 인스턴스 변수 b_value가 정의되어 있지 않다는 것이다. 

print(d.b_value)

 위의 코드를 확인하면 아래와 같이 에러가 발생하는 것을 알 수 있다.

 이와 같이, 있어야할 인스턴스 변수가 정의되어 있지 않는 것을 알 수 있다. 적절하게 초기화하기 위해서는 __init__메소드가 모두 호출되어야할 필요가 있다. 그리고 그러기 위해서는 물론 super()함수를 사용하여 상속 계층에 포함되어 있는 모든 __init__메소드가 호출되도록 할 필요가 있다.

 

 

super함수와 MRO


여기서 먼저 아까의 코드의 다음과 같이 수정해보자.

class B:
    def __init__(self):
        self.b_value = 'B'
        print('class B init')

class C:
    def __init__(self):
        self.c_value = 'C'
        print('class C init')

class D(C, B):
    def __init__(self):
        print('class D init')
        super().__init__()

 이것은 object 클래스를 다이아몬드의 꼭지점으로써 B클래스와 C클래스가 상속받고 있고, D클래스는 C클래스와 B클래스를 상속받고 있는 코드이다. 그리고, D클래스의 __init__메소드는 기저 클래스의 __init__메소드를 호출하고 있다.

 그럼 아래의 코드로 다시 클래스 D의 MRO를 살펴보자.

d = D()
print(D.__mro__)

 이 코드를 실행시키면, 다음과 같이 결과가 출력된다.

 위의 출력 결과대로, MRO는 "D > C > B > object"가 되므로, super함수를 통해서 C클래스의 __init__가 호출되는 것을 알 수 있다. 그러므로, 이것으로 아까와 동일하게 C클래스의 __init__메소드에 적혀있는 인스턴스 변수 c_vlaue의 초기화 되지 않는다. 여기서는 B클래스의 포함한 모든 클래스를 초기화하고 싶기때문에, __init__메소드의 연계가 필요하다. 그러기 위해서는 다음과 같이 C클래스와 B클래스의 __init__메소드에서도 "super().__init__" 호출을 작성하면 된다.

class B:
    def __init__(self):
        self.b_value = 'B'
        print('class B init')
        super().__init__()

class C:
    def __init__(self):
        self.c_value = 'C'
        print('class C init')
        super().__init__()

class D(C, B):
    def __init__(self):
        print('class D init')
        super().__init__()

 이 상황에서 다음의 코드를 실행시키면 결과를 얻을 수 있다.

d = D()

 "D > C > B" 이라는 MRO에 기재된 순서로 __init__메소드가 호출되어 초기화되는 것을 알 수 있다. 그러나, 이 코드에서는 B클래스와 C 클래스 사이에 아무런 관계가 없다. C클래스의 __init__메소드에서 "super().__init__()"이라고 적으면, 그것을 object클래스의 __init__메소드가 호출된다고 생각될 수 있지만, 그렇지 않다. 실제로는 MRO에 정렬된 순으로 메소드를 연계적으로 호출하는 구조가 되어있다. 따라서 "D > C > B > object"의 순서로 __init__메소드가 호출되는 것이다. 클래스를 상속받을 때에는 그것이 단일 상속이든 다중 상속이든 __init__메소드로 초기화 연계를 잊어버리지 않도록 하자.

 

 

파생 클래스로부터 특정 기저 클래스의 메소드를 호출하기


 아까의 예에서는 다이아몬드 상속으로 움직이고 있는 경우였지만, 아래에서는 object클래스가 아닌, A클래스와 그 파생 클래스인 B 클래스, object클래스의 기저 클래스인 C클래스부터, D클래스를 작성해보자(아까 봤던 형태). 

 또한, 여기에서는 __init__메소드가 아닌, 다시 hello메소드 예로 4개의 클래스를 정의(혹은 어버라이드)해보자. 이 때, A클래스와 C클래스에서는 기저 클래스가 될 object클래스에는 hello메소드가 없으므로, "super().hello()" 호출 부분을 작성하지 않고 B 클래스와 D 클래스의 hello메소드에서만 "super().hell()"를 작성하자.

class A:
    def hello(self):
        print('Hello from A')

class B(A):
    def hello(self):
        print('Hello from B')
        super().hello()

class C:
    def hello(self):
        print('Hello from C')

class D(B, C):
    def hello(self):
        print('Hello from D')
        super().hello()
d = D()
print(D.__mro__)
d.hello()

결과는 다음과 같다. 

 MRO에 의해 D > B > A의 순서로 super함수를 통한 hello메소드의 호출이 연계되지만, C클래스의 메소드를 호출되지 않았다. D클래스의 hello메소드로부터 C클래스의 메소드를 호출하고 싶은 경우는 아래의 코드와 같이 명시적으로 클래스를 기재하면 된다.

class D(B, C):
    def hello(self):
        print('Hello from D')
        C.hello(self)

 그 이후 다시 다음의 코드를 실행하면 결과는 다음과 같이 나온다.

d = D()
print(D.__mro__)
d.hello()

 MRO대로가 아닌, D클래스의 hello메소드로부터 C클래스의 hello 메소드가 호출된다.

 상속을 실행할 때에는 상속소스가 되는 클래스에서는 그곳으로부터 파생된 클래스에 대한 무언가를 상정하여 코드를 작성하는 것은 할 수 없다. 이것이 가능하게 된다면, A클래스의 hello()메소드에 "super().hello()" 호출을 추가하면 MRO에 있어서 A의 다음에 있는 C클래스의 hello메소드가 호출되도록 할 수 있을 것이다. 

 이 경우, A클래스를 상속받지 않는 (혹은 동일한 이름의 hello메소드를 가진) C 클래스와, A클래스를 상속받는 B클래스라는 2개의 클래스가 정의되어 있다면, 위와 같은 기술이 가능하다. 그러나, 정말로 그렇게 될지는 아무도 모른다.

 따라서, 어떤 클래스를 상속받을 때에는 오버라이드한 메소드로부터 기저 클래스의 메소드를 스스로 상정하고 있는것과 같이 연계하도록 하기 위해서는 어디까지나 뒤에서부터 클래스를 정의하는 쪽이 조정할 필요가 있다(물론, 어떤 프레임워크에 따라, 이 이용자에 대하여 "이 클래스를 이용하려면, 이용하는 쪽에서 이것과 이것을 이처럼 메소드를 정의하여, 저 곳에서 이것과 이것을 이와 같이 처리하지 않으면 안 된다"라는 것과 같이 이용자에게 강제하는 것이 가능하지만, 여기서는 그것까지 상정하지 않는다. 어쨌든 다른 사람이 만든 클래스를 이용하는 경우에는 그 작성법과에 따르면서 자신이 하고자 하는 처리를 작성해야할 것이다).

 하나 더, super함수 호출에 인수를 전달하는 것으로, 동일한 것을 실현할 수 있다. 아래의 예를 살펴보자.

class D(B, C):
    def hello(self):
        print('Hello from D')
        super(A, self).hello()

d = D()
print(D.__mro__)
d.hello()

 코드의 실행결과는 다음과 같다.

 자세한 설명은 생략하지만, "super(A, self).hello()" 에 의해 MRO로 "A"다음의 클래스 즉 C가 검색되어 이 인스턴스 메소드인 hello가 self를 사용하여 호출되고 있다. 이 방법에서도, MRO로 표시된 순서에 따르지 않고, D클래스의 hello메소드부터 C클래스의 hello메소드가 호출되고 있다. 그러나, super함수의 동작과 MRO의 값을 이해한 후에 이러한 코드를 작성하는 것보다도, 위에서 본 것과 같이 C.hell(self)와 같이 작성하는 것이 좋을 것이다.

 다중 상속을 할 때에는 이 메소드 호출 순서가 어떤 문제가 일어날 가능성이 있는가와 Python에서는 MRO를 알아보는 것으로 메소드 호출의 resolution 순서를 명확히 알 수 있다는 것을 알아두자. 


참고자료

https://www.atmarkit.co.jp/ait/articles/1908/27/news027.html

728x90