객체지향 프로그래밍의 역사는 오래 되었고 많은 언어가 있는데, 그 중 python은 조금 독특한 형태의 객체지향 형태를 만들었다. 객체지향에서 가장 기초가 되는 단위가 object(=instance)인데, python에서는 모든 값이 객체이다. 따라서 모든 값에 dir()
함수를 사용할 수 있다.
python의 가장 중요한 특징은 functional programming이다. 그런데 앞서 살펴본 것처럼 class에 __call__
메소드를 지정하므로써 괄호 연산을 할 수 있게 되었고, class도 함수처럼 사용할 수 있게 되었다. 따라서 class와 functional programming의 경계가 무너지게 되었다. 이를 multi paradigm이라고 한다. 객체지향의 아래와 같은 특징이 있다.
객체지향은 여러 기능을 하나로 묶는 것이다. 그래서 그 안에 정보를 숨길 수 있다.(=은닉화/information hiding) 그런데 pyhthon은 class 객체에 대해서는 밖에서 접근이 가능했다. instance 변수를 추가할 수 있었기 때문이다. 하지만 함수에 대해서는 LEGB 규칙이 적용되어 정보 은닉이 가능하다는 특징을 가지고 있다.
다형성은 같은 이름을 가진 함수나 클래스이지만 서로 다른 동작을 할 수 있는 특징을 의미한다. 예를 들어 + 연산은 정수 데이터에 사용할 때, 문자열 데이터에 사용할 때 그 연산과 결과가 다르다. 이는 모드 다형성 덕분이다. (-> single dispatch를 지원하기 때문에 구현 가능하다.)
다형성을 구현하는 두 가지 방법은 오버로딩과 오버라이딩이 있다. 오버로딩은 같은 class 안에서 다른 동작을 하는 메소드를 구현하는 것이고, 오버라이딩은 상속과 관련되어 부모 클래스에 있는 메소드(혹은 변수)를 새롭게 정의하는 것이다.
상속은 class에서 값을 지정하는 일에 있어 자식 class에서 할 수 없는 일을 부모 class에게 위임하는 것이다. 아래와 같은 문법을 사용한다.
class Child(Parent):
pass
super는 부모클래스 자체를 의미한다. 그래서 self나 cls와 같은 객체를 의미하는 인자를 넣지 않아도 된다. super를 사용하지 않았을 때의 문제를 살펴보자.
# super를 사용하지 않기
class A:
def __init__(self):
print('A')
class B(A):
def __init__(self):
A.__init__(self)
print('B')
class C(A):
def __init__(self):
A.__init__(self)
print('C')
class D(B,C):
def __init__(self):
# 부모가 가진 기능을 전부 가져오고 싶을 때!!
B.__init__(self)
C.__init__(self)
print('D')
>>> d = D()
A
B
A
C
D
왜 이렇게 될까? D()를 인스턴스화 하면 탐색 순서는 D → B → A → C → A이다. 그런데 탐색 순서는 stack에 담기 때문에 담긴 순서대로 다시 뺀다면 그 순서가 뒤바뀌어 위와 같이 출력되는 것이다.
이번에는 super를 사용해 동일하게 구현해보자.
# 위의 경우를 super로 구현해보자
class A:
def __init__(self):
print('A')
class B(A):
def __init__(self):
super().__init__()
print('B')
class C(A):
def __init__(self):
super().__init__()
print('C')
class D(B,C):
def __init__(self):
super().__init__() # super를 한 번만 써도 B와 C를 동시에 돌았다
print('D')
>>> dd = D()
A
C
B
D
super는 상속 체계를 확인하며 동작하는 특징이 있다. 따라서 super는 부모 class를 한 번 다 돌고 중복이 없게끔 method 순서를 만든다. 탐색 순서는 첫 예제와 마찬가지로 D → B → A → C → A 이지만, 중복을 제거하기 때문에 B 다음에 오는 A는 삭제한다. 따라서 결과가 위와 같이 나오게 된 것이다.
객체지향의 마지막 특징은 추상화이다. 그런데 추상화를 정확히 이해하기 위해서는 metaclass에 대한 이해가 필요하다. 따라서 아래 metaclass를 먼저 설명하고 그 다음에 추상화에 대해 알아보자.
class에 대해 공부할 때 class 선언에는 숨겨진 사실이 있다고 이야기 했다.
class A(object, metaclass=type):
pass
기본적으로 metaclass가 type으로 지정되어 있고, 이 때문에 type(int)
나 type(list)
와 같이 class에 데이터 타입을 찍어보면 type이라고 나온다. 그렇다면 met class가 무엇일까?
metaclass는 비슷한 동작을 하는 class들을 하나로 묶어 주요한 특징들을 갖게끔 하는 큰 범주로 생각하면 된다. 마치 객체의 행동을 결정하는 것이 class인것 처럼, class의 행동과 특성을 결정하는 것이 바로 metaclass인 것이다.(class 상위의 class라고 생각하면 편함)
metaclass에서 가장 중요한 점은 같은 metaclass에 해당하는 class들은 같은 성질 및 동작을 가져야 한다는 것이다. 그래야 class의 동작을 통제할 수 있는 것이기 때문이다.
metaclass를 만드는 방법은 python 공식 문서를 통해 확인 할 수 있다. 공식 문서에서는 총 3단계에 걸쳐 metaclass를 만들어 사용하라고 권장하며 각각의 단계는 아래와 같다.
위 단계를 코드를 통해 알아보자.
# 1단계
class MyType(type) : # type이라는 metaclass를 상속받기
pass
# 2단계
class MySpecialCalss(metacalss=MyType):
pass
# 3단계
class MySubClass(MySpecialCalss):
pass
그래서 우리는 3단계로 정의한 MySubClass를 실제 코드에서 활용하게 된다.
위에서 알아본 순서에 맞춰 실제로 metaclass를 구현해보자.
# 메타 클래스 구현하기
class Meta(type):
def __call__(self) : # 메타클래스에 괄호를 붙일 때의 행동을 결정해줌 -> closure 기법 사용 가능
print('A')
# 실제로는 2단계까지만 구현해도 사용이 가능하다.
class S(metaclass=Meta):
def __init__(self): # 클래스에 괄호를 붙일 때의 동작을 __init__에서 결정할 수 있다.
print('AA')
>>> s = S()
A
실행 결과를 보니, 조금 이상하다. 분명 S에도 __init__이 있으므로 출력값은 AA가 되어야 할 것 같은데, 실제 출력 값은 A이다. 이를 통해 metaclass에 __call__이 선언되어 있으면, __init__보다 우선권을 가진다는 것을 알 수 있다.
이번에는 객체의 개수를 1개로 제어하는 코드를 작성해보자. (=singletone pattern)
# metaclass
class Meta(type):
instance = None # 인스턴스 개수는 최초에 0개로 초기화
def __call__(cls) :
if not cls.instance: # 만약 객체가 없으면
cls.instance = super().__call__() # 객체 생성
return cls.instance
# 객체가 있으면 아무 동작 안함 => 결국 해당 클래스의 객체는 1개만 유지되도록 하기
class S(metaclass=Meta):
def __init__(self):
pass
>>> s = S()
>>> a = S()
>>> b = S()
>>> id(s)== id(a), id(a) ==id(b)
(True, True)
class S로 s, a, b 객체를 각각 생성했다. 하지만 id 값을 비교한 결과 세 메모리 주소값은 동일했다. 따라서 세 객체는 모두 같은 것으로 판단한다.
이렇게 metaclass로는 무엇이든 만들 수 있다. instance의 개수를 조절할 수 있듯이 특정 instacne를 구현하지 않으면 에러가 나게 만들 수도 있다. 이 방법이 추상화이다.
객체지향의 마지막 특징인 추상화를 구현하기 위해 metaclass를 이용한다. metaclass를 통해 특정 method(혹은 instance)를 구현하지 않으면 에러를 발생시켜 더이상 진행 못하게 하는 방법이 이 바로 추상화다. 아래 예제를 보자. 위에서는 metaclass부터 우리가 만들었지만, 이미 구현되어있는 metaclass를 사용해보도록 하자.
# 일부러 에러 나는 코드 작성하기
from abc import ABCMeta, abstractmethod
# 1단계는 ABCMeta 사용할 것
# 2단계
class T(metaclass=ABCMeta):
def x(self):
print('x')
@abstractmethod # 데코레이터를 통해 abstrac method 구현
def y(self):
print('y')
# 3단계 -> 일부러 method y를 구현하지 않을 것
class TT(T):
pass
>>> t = TT()
TypeError: Can't instantiate abstract class TT with abstract method y
이처럼 abstrac method로 구현되어있는 메소드 y를 구현하지 않자 TypeError가 발생한다.
그런데 왜 이런 식으로 어떤 method를 강제하도록 만들어진걸까? 바로 동작에 리소스가 많이 들어가는 코드에 구현이 미흡하여 동작이 중단되면 금나큼 리소스가 허비되는 것이기 때문이다.
예를 들어 딥러닝 모델을 구현할 때 중간 부분에 구현이 덜 되었다고 가정하자. 그런데 개발자는 구현이 완료되었는 줄 알고 2일 3일 동안 코드 실행을 하지만, 구현이 덜 된 부분 때문에 코드 실행이 중단되는 불상사가 발생할 수 있기 때문에 abstrac method로 설정해 구현이 되지 않으면 애초에 코드 실행이 불가하게 설정하는 것이다.
abstrac method와 같이 처음부터 코드 실행을 중단하는 방법이 있든가 하면, 중요도가 상대적으로 떨어지는 method의 경우 그 강제력을 조금 낮출 수 있다. 위와 동일한 코드지만 class T에 새로운 method를 추가해보았다.
class T(metaclass=ABCMeta):
def x(self):
print('x')
@abstractmethod
def y(self):
print('y')
def z(self):
raise NotImplementedError
class TT(T):
def y(self):
print('t-y')
>>> tt = TT()
>>> tt.z()
NotImplementedError:
abstract method로 정의되지 않은 class z는 class TT에서 오버라이딩 하지 않아도 별다른 문제가 없다. 그런데 TT 객체가 z를 실행하려고 하자 NotImplementedError를 발생시켰다. 이처럼 중요도가 조금 부족한 method는 강제력을 조금 낮출 수 있다.