본문 바로가기

진리는어디에/Python

[Python] 클래스(class) #8 상속

이번 포스트에서는 파이썬 클래스의 상속에 대해서 알아 보도록 하겠다. 상속의 기본 문법을 시작으로 상속과 관련된 문제와 그 문제들의 해결 방법을 알아 본다.

목차

  1. 파이썬 클래스 소개
  2. instance vs static
  3. dict vs slots
  4. property의 활용
  5. special method
  6. callable object
  7. 클래스 데코레이터(class decorator)
  8. >> 상속
  9. 추상 클래스와 추상 메소드

클래스 상속이란

클래스의 '상속'이란, 기존에 정의 되어 있는 클래스에 정의 된 속성(필드와 메소드들)을 이어 받아, 그대로 사용하거나 수정 또는 다른 속성들을 추가하여 사용하는 것을 말한다. 기존에 정의 되어 있던 클래스를 기초 클래스(base class) 또는 부모 클래스(parent class), 상위 클래스(super class)라고 하며, 상속을 통해 새롭게 생성되는 클래스를 파생 클래스(derived class) 또는 자식 클래스(child class), 하위 클래스(sub class)라고 한다.

기존에 정의 되어 있는 클래스에 정의 된 속성(필드와 메소드들)을 이어 받아,
그대로 사용하거나 수정 또는 다른 속성들을 추가하여 사용하는 것

상속의 기본 문법

class 자식클래스(부모클래스) :
    ....

클래스 상속의 기본 문법은 위와 같다. 기존 클래스 정의 문법에 상속을 받고자 하는 클래스의 이름을 괄호로 감싼 부분이 추가 되었다. 실제 코드를 작성하면 아래와 같다.

class Base :           # 기본 클래스
    def foo(self) :
        print('Base foo')
    
    def bar(self) :
        print('Base bar')

class Derived(Base) :  # 상속을 받는 파생 클래스
    
    def bar(self) :    # 부모 클래스로 부터 상속 받은 함수를 재정의(Override)
        print('Derived bar')
        
d = Derived()
d.foo()                # Derived는 foo()함수가 없지만 부모로 부터 상속 받은 foo() 함수 호출
d.bar()                # Derived 클래스에서 재정의한 bar() 함수 호출

Base클래스는 foo() 와 bar() 메소드를 정의하고 있고, Base클래스를 상속 받은 Derived 클래스는 bar() 메소드를 '재정의(override)'하고 있다. 메소드를 재정의 한다는 것은 부모 클래스로 부터 물려 받은 속성을 사용하지 않고, 자신의 속성을 사용하겠다는 의미다.

14라인에서 foo()를 호출하면, Derived 클래스에는 foo() 메소드가 정의 되어 있지 않지만 부모로 부터 상속 받은 foo()를 사용하여 'Base foo'를 출력한다.

하지만 15라인의 bar() 메소드의 경우, Derived 클래스에서 재정의 했으므로, Base클래스의 bar()가 아닌 Derived 클래스의 bar() 메소드를 호출 하여 'Derived bar'를 출력하게 된다.

만일 파생 클래스에서 기반 클래스의 메소드를 호출 하고 싶다면 다음과 같이 기반 클래스의 이름을 이용하는 방법과 super()를 이용하는 두 가지 방법이 있다.

class Derived(Base) :  # 상속을 받는 파생 클래스
    
    def bar(self) :    # 부모 클래스로 부터 상속 받은 함수를 재정의(Override)
        print('Derived bar')
        
        Base.bar(self) # 기반 클래스의 이름을 이용하는 방법
        super().bar()  # super()를 이용하는 방법

위에서 super는 MRO(Method Resolution Order) 순서에 따른 '다음' 클래스를 객체를 리턴하는 함수다. super()를 통해 다음 클래스(일반적으로 기반 클래스)의 메소드를 사용 할 수 있다. super()에 대한 내용은 포스팅 후반부에 자세히 설명 된다. 지금은 기반 클래스의 메소드를 호출하는 방법으로 기반 클래스의 이름을 이용하는 방법과 super()를 이용하는 방법, 이렇게 두 가지가 있다는 것만 기억 하자.

상위 클래스의 메소드는 클래스 이름과 super()를 통해 호출 될 수 있다.
반응형

상속과 __init__메소드

파이썬 상속에서 종종 실수 하는 부분 중에 하나가 - 특히 다른 프로그래밍 언어의 경험이 있는 경우라면 더욱 - 파생 클래스의 생성자에서 기반 클래스의 생성자를 호출해주지 않는 것이다.

아래 코드는 실행하면 Derived 클래스에서 x라는 속성을 찾을 수 없다는 에러를 발생 시킨다.

class Base :
    def __init__(self) :
        self.x = 100
        
    def print(self) :
        print(self.x)
        
class Derived(Base) :
    def __init__(self) :
        pass

b = Base()
b.print()

d = Derived()
d.print()  # 'Derived' object has no attribute 'x'

파이썬은 Derived의 객체를 만들면 Derived 의 __init__() 만 호출 하고 Base 클래스의 __init__() 은 호출 해주지 않는다. 그래서 위의 예제에서는 필드 x를 생성해주는 Base 클래스의 __init__()이 호출 되지 않아 x가 생성 되지 않았고, print()함수를 호출 했을 때 정의 되지 않은 필드를 호출 하려고 하여 에러가 발생한 것이다.

Tip. C++, C#, Java와 같은 언어는 파생 클래스의 생성자가 호출 되면 자동으로 상위 클래스들의 생성자들이 모두 호출해주기 때문에 다른 언어를 사용해본 경험이 있는 사람들은 종종 이 부분에서 실수 한다.

파이썬에서 상속을 하게 된다면 파생 클래스의 __init__() 메소드에서 기반 클래스의 __init__() 메소드를 명시적으로 호출 해주어야 한다. 호출 방법은 클래스 이름으로도 가능하고, super()를 이용 할 수도 있지만 super()가 '항상' 권장된다.

class Base :
    def __init__(self) :
        self.x = 100
        
    def print(self) :
        print(self.x)
        
class Derived(Base) :
    def __init__(self) :
        super().__init__()  # 기반 클래스의 생성자를 명시적으로 호출

b = Base()
b.print()

d = Derived()
d.print()

다시 Derived 클래스를 수정하고 실행하면 아무런 문제 없이 실행 되는 것을 볼 수 있다. 어렵지 않은 내용이지만 종종 실수하는 부분이므로 다시 한번 마음에 새기고 다음으로 넘어가자.

기반 클래스의 __init__() 메소드를 명시적으로 호출 해주어야 한다. 
super()를 이용하는 방법이 '항상' 권장된다.

다중 상속과 MRO(Method Resoultion Order)

파이썬은 여러개의 클래스로 부터 동시에 상속 받는 '다중 상속'을 지원한다. 다중 상속이란 여러 개의 기반 클래스로 부터 동시에 모든 속성들을 물려 받는 것을 말한다. 그럼 아래와 같이 A 클래스와 B 클래스 모두가 똑같은 메소드를 가지고 있다면 C클래스에서 호출하는 foo() 메소드는 어느 클래스에서 상속 받은 foo() 메소드일까?

class A :
    def foo(self) :
        print('A foo')

class B :
    def foo(self) :
        print('B foo')
        
class C(A, B) :
    pass
        
c = C()
c.foo() # 어느 클래스의 foo가 호출 될까?

정답은 이름이 겹칠 땐, 먼저 상속 받은 클래스의 것을 사용한다. 위의 경우에는 A클래스의 foo() 메소드를 사용한다. 만일 C(B, A)와 같은 순서로 상속 받았다면 B의 foo를 사용하게 된다.

이렇게 어떤 메소드를 사용할지 순서를 결정하는 것을 MRO(Method Resolution Order), 직역하면 "메소드 결정 순서"라고 한다. 클래스의 MRO 순서를 알아 보기 위해 "클래스이름.mro()"를 사용 할 수 있다.

>> print(C.mro())
[<class '__main__.C'>, <class '__main__.A'>, <class '__main__.B'>, <class 'object'>]

클래스 C의 MRO는 C -> A -> B -> object(시스템에서 지정하는 최상위 클래스) 순서라고 나온다.

자, 그럼 객체 c를 이용해 B의 foo() 호출하려면 어떻게 해야 할까? 물론 클래스 상속 순서를 변경하면 되지만, 상속 순서를 재정의하는 것 말고, foo()를 호출하는 부분에서 B의 foo()를 호출하려면 어떻게 해야 할까? 답은 아래 처럼 super()를 사용하면 된다.

super(A, c).foo() # B의 foo 호출

갑자기 super() 함수에 인자가 들어가고 심지어 나는 B클래스의 foo를 호출하고 싶은데 A클래스가 나온다. 도데체 이게 무슨 말일까? 지금 부터 설명 하도록 하겠다.

super(타입, 객체).메소드()

super 함수는 MRO 순서 에서 타입으로 지정된 클래스의 '다음' 순서의 클래스를 리턴한다. 예를 들어 아래와 같이

super(C, c).foo()

타입에 C를 넘기게 된다면, "나는 c객체에 대해 foo를 호출 할껀데 c객체의 타입이 C다. C의 super, 즉, MRO 순서상 다음 클래스를 찾아서 그 클래스의 foo() 메소드를 호출 하라"라는 뜻이다. 앞에서 mro() 함수를 통해 살펴본 순서는 "C -> A -> B"였다. C타입의 다음 순서는 A 클래스다. 그래서 A의 foo()를 호출하게 된다.

그럼 다시 처음 super(A, c).foo()로 돌아가보자. 이 코드는 c객체를 A클래스의 객체로 보고 A타입의 다음 순서 즉 B. B의 foo를 호출한다.

다이아몬드 상속과 super()

하나의 기반 클래스에서 두 개의 클래스가 상속 받고, 그 두개의 클래스를 다중 상속 받는 클래스가 있다면 이것을 다이아몬드 상속이라고 한다.

class A :
    def __init__(self) :
        print('A __init__')

class B(A) :
    def __init__(self) :
        print('B __init__')
        A.__init__(self)

class C(A) :
    def __init__(self) :
        print('C __init__')
        A.__init__(self)
        
class D(B, C) :
    def __init__(self) :
        print('D __init__')
        B.__init__(self) # 다중 상속이라 각 상위 클래스의 생성자를 따로 불러준다.
        C.__init__(self)
        
d = D()
print(D.mro()) # [<class '__main__.D'>, <class '__main__.B'>, 
               # <class '__main__.C'>, <class '__main__.A'>, <class 'object'>]

위 클래스의 상속 구조를 보면 아래 그림 처럼 마치 다이아몬드 형태를 이루고 있다.

https://velog.io/@chldppwls12/Python-%EC%83%81%EC%86%8DInheritance%EC%98%A4%EB%B2%84%EB%9D%BC%EC%9D%B4%EB%94%A9Overriding

위 구조에서 클래스 D의 객체를 생성하면, D는 B와 C 두 개의 클래스로 부터 다중 상속을 받고 있으므로 __init__()에서 각 기반 클래스들의 __init__() 메소드를 명시적으로 호출하고 있다. 다시 B는 __init__()이 A의 __init__을 호출하고, C의 __init__()도 A의  __init__()을 호출하게 되어 A의 __init__()은 결국 두 번 호출 된다. 기반 클래스의 __init__을 직접 호출하게되면 이러한 문제가 발생한다.

이런 문제는 super()를 사용하면 깔끔히 해결 된다.

class A :
    def __init__(self) :
        print('A __init__')

class B(A) :
    def __init__(self) :
        print('B __init__')
        super().__init__()  # super(self.__class__, self).__init__()

class C(A) :
    def __init__(self) :
        print('C __init__')
        super().__init__()  # super(self.__class__, self).__init__()
        
class D(B, C) :
    def __init__(self) :
        print('D __init__')
        super().__init__()  # super(self.__class__, self).__init__()
        
d = D()
print(D.mro()) # [<class '__main__.D'>, <class '__main__.B'>, 
               # <class '__main__.C'>, <class '__main__.A'>, <class 'object'>]

super()에 아무런 인자를 넘겨주지 않으면 파이썬에는 다음과 같은 디폴트 인자를 사용하게 된다.

super(self.__class__, self)

이것은 자신의 클래스 이름과 self 객체를 인자로 넘기는 것이다. 이렇게 되면 MRO 순서에 따라 D의 super는 B고 B의 super는 C, C의 super는 A가 되어 각각 __init__이 한번씩만 호출 된다.

이렇게 super()하는 것은 중요하다. 설령 이해가 안된다고 하더라도 상위 클래스의 메소드를 호출 할 때는 무조건 super()를 이용해야만 한다고 기억해두자.

기반 클래스의 메소드를 사용 할 때는 항상 super()를 이용한다

메소드 오버라이드와 name mangling

클래스 시리즈 제일 처음 강의에서 네임 맹글링(name mangling)에 대해 언급했었다. 파이썬에서는 public private와 같은 원천적인 접근 권한 한정자를 지원하지 않는 대신 필드 이름이나 메소드 이름 앞에 언더바 두 개('__')를 붙이면 원래의 이름을 '_클래스이름__변수또는함수이름'과 같이 변경 해버린다.

이런 맹글링에 대해 제대로 이해하지 못하고 메소드를 재정의(override)하면 아래와 같은 원치 않는 상황이 발생 할 수도 있다.

class Base :
    def f1(self) :     print('Base f1')
    def _f2(self) :    print('Base _f2')
    def __f3(self) :   print('Base __f3') # _Base__f3()
    
    def foo(self) :
        self.f1()
        self._f2()
        self.__f3()    # self._Base__f3() 을 호출 한다
        
class Derived(Base) :
    def f1(self) :     print('Derived f1')
    def _f2(self) :    print('Derived _f2')
    def __f3(self) :   print('Derived __f3') # _Derive__f3()
    
d = Derived()
d.foo()

Derived가 f1, _f2, __f3 메소드들을 재정의 했다. foo()함수를 호출하게 되면 f1, _f2는 재정의한 함수가 잘 호출 되지만, __f3의 경우에는 재정의한 함수가 아닌 Base의 __f3가 호출 된다.

이는 이전에 배웠던 함수나 변수 이름 앞에 __가 붙으면 네이밍 맹글리에 의해 다른 이름이 되어 버렸기 때문이다. 그래서 재정의 했다고 생각하지만 사실은 다른 함수를 호출한 것이다.

함수의 이름을 확인해보기 위해 __dict__를 조사해보면 아래와 같이 출력 된다.

>> print(Base.__dict__)
{'__module__': '__main__', 'f1': <function Base.f1 at 0x0000019441AD1430>, '_f2': <function Base._f2 at 0x0000019441AD14C0>, '_Base__f3': <function Base.__f3 at 0x0000019441AD1550>, 'foo': <function Base.foo at 0x0000019441AD15E0>, '__dict__': <attribute '__dict__' of 'Base' objects>, '__weakref__': <attribute '__weakref__' of 'Base' objects>, '__doc__': None}

>>  print(Derived.__dict__)
{'__module__': '__main__', 'f1': <function Derived.f1 at 0x0000019441AD1670>, '_f2': <function Derived._f2 at 0x0000019441AD1700>, '_Derived__f3': <function Derived.__f3 at 0x0000019441AD1790>, '__doc__': None}

Base 클래스와 Derived 클래스에서 각각 __f3() 메소드를 다른 이름으로 바꾸어 저장하고 있는 것을 볼 수 있다. 결론은 메소드를 만들 때 앞에 언더바 두 개를 붙여서 만들면 파생 클래스에서 재정의 할 수 없다.

언더바 두 개를 앞에 붙여 __xx() 형태로 메소드를 만들면 파생 클래스에서 재정의 하지 못 한다.

마치며

이상 파이썬 클래스의 상속과 그에 따라 발생 할 수 있는 문제점과 해결 방법을 살펴 보았다. 다음 강의는 클래스 시리즈의 마지막으로 #9 추상 클래스와 추상 메소드에 대해 살펴 보도록 하겠다.

부록 1. 같이 보면 좋은 글

유익한 글이었다면 공감(❤) 버튼 꾹!! 추가 문의 사항은 댓글로!!