꾸준함이 중요한 Lion2me의 기술블로그

Python Basic 6 - Meta Class And Attribute

12 Apr 2022

Python Basic 6 - Meta Class And Attribute

메타 클래스와 애트리뷰트

이 포스트는 Effective Python를 번역 한 “파이썬 코딩의 기술”을 읽고 적는 포스트입니다.


세터와 게터 대신 평범한 애트리뷰트

자바와 같은 언어, 특히나 캡슐화가 잘 구현되어 있는 객체지향 언어에서는 주로 세터와 게터를 이용해서 속성을 얻어오는 방식이 사용됩니다. 일반적으로 애트리뷰트에 접근을 제한하고 값을 이용하기 위해서 사용됩니다.

하지만 파이썬에서는 private나 protected 등 접근제한자를 강하게 제공하지 않습니다., 오픈소스를 보면 _로 표시 되어 있는 메서드를 많이 볼 수 있습니다. 이러한 특징은 protected를 표현 한 것이지만, 실제로 어떤 접근 클래스든 해당 값에 접근 할 수 있습니다. 즉 protected는 명시적으로 표현 할 뿐 접근 할 수 있습니다.

마찬가지로 private도 __를 명시적으로 붙여주고 접근 할 때 동일한 변수 명으로는 접근 할 수 없지만, 변수는 클래스의 애트리뷰트에 생성되어 있음을 알 수 있습니다.

class capture_test:
    def __init__(self):
        self.__a = "private"
        self._b = "protected"
        self.c = "public"
        
a = capture_test()
a.__dict__

{'_capture_test__private': 'private',
 '_protected': 'protected',
 'public': 'public'}
 

정확히 말하면 __를 이용해서 private를 표현 한다고 해도 클래스 명을 이용해서 애트리뷰트 이름을 바꿀 뿐 접근이 안되는 것은 아닙니다.

그러니 만약 “이 변수는 자식 클래스에서 변형하면서 기능의 차이를 줄 수 있는 트리거로 사용 할거야!” 라고 생각하더라도 굳이 getter에 protected를 표현 할 필요가 없습니다. ( 이후 코드 확인을 위해 변수 명에는 _를 붙이는 것은 좋은 것 같습니다. )

그래도 이런 문제는 있을 수 있습니다.

“학생에게 점수를 줄 때 100점보다 많이 주면 안되는데?” 이러한 예외가 일어나지 않도록 프로그램을 설계할 때도 setter가 이용되기도 합니다. 혹은 getter에서 정보를 불러오는 시점에도 별도의 이벤트가 발생 할 수도 있습니다.

다음 장의 @Property에서 방법을 정리해보겠습니다.

애트리뷰트를 일일이 리펙터링하지말고 @Property 고려

이전 장에서 파이썬에서 다음과 같은 코드는 지양한다고 말했습니다.

def get_attr1(self):
    return self.a
def set_attr1(self,value):
    self.a = value

그럼 어떻게 바꿀 수 있을까요?

파이썬에서는 이러한 기능을 할 수 있는 데코레이터를 제공하고 있습니다. 그 데코레이터가 바로 @property입니다. 설명하기보다 간략한 예제를 먼저 보겠습니다.

class score:
    def __init__(self, value):
        self._score = value
    
    @property
    def score(self):
        return self._score
    
    @score.setter
    def score(self,value):
        self._score = value
    
    
a = score(89)
print(a.score)
a.score = 32

# 89
# 32

여기서 주목 할 부분은 print를 한 속성값이 @property로 묶은 함수 명이라는 것과 그러한 score를 설정 한 부분이 score.setter라는 것 입니다.

즉 @property는 정확히는 특정 애트리뷰트를 getter로 만드는 것이 아닌 또 하나의 애트리뷰트를 만드는 것이라고 생각하면 편합니다. 대신 그 용도가 애트리뷰트를 이용 한 값을 리턴함을 확실히 하면 됩니다.

그리고 setter의 경우에는 해당 property의 .setter를 데코레이터로 사용하면 됩니다. 만약 @property로 애트리뷰트를 만들어놓고 setter를 만들지 않으면 해당 애트리뷰트로 값을 변경 할 수 없습니다.

class score:
    def __init__(self, value):
        self._score = value
    
    @property
    def score(self):
        return self._score
        
a = score(89)
print(a.score)
# 89

# a.score = 32
# error

# a._score = 32
# 이런 식으로 변수에는 접근 할 수 있다.

이걸 이용해서 점수가 100점 이상이 되지 않도록 해보겠습니다.

class score:
    def __init__(self,first_name, last_name):
        self._score = 0
        self._first_name = first_name
        self._last_name = last_name
    
    @property
    def score(self):
        return self._score
    
    @property
    def full_name(self):
        return f"{self._first_name}_{self._last_name}"
    
    @score.setter
    def score(self,value):
        # try : except : 로 예외처리도 가능
        if value < 0:
            value = 0
        elif value > 100:
            value = 100
        self._score = value
    
    
a = score("lion2","me")
a.score = 134
print(a.score)
a.score = -5
print(a.score)
print(a.full_name)

# 100
# 0
# lion2_me

여기서 만약 try / except로 예외 처리를 한다면 더욱 유연하게 활용 할 수 있습니다. @property를 이용한 getter 또한 출력하고자 하는 값을 유연하게 정할 수 있습니다. first_name과 last_name을 이용한 fullname이라는 새로운 property를 만들어내는 것을 보면 유연하게 활용 할 수 있음을 알 수 있습니다.

하지만 property를 이용해서 속성을 불러오는 과정이 너무 많은 자원을 사용하게 된다면 예상치 못한 문제를 겪을 수 있습니다. 예를 들면 값을 불러 올 때마다 디스크에서 불러오는 IO작업이 수행되거나 DB에서 값을 가져오는 등 네트워크 IO가 실행되어버리면 해당 값을 여러 번 접근하는 로직이 수행 될 때 큰 문제가 발생 할 수 있습니다.

이럴 때는 메서드를 작성하도록 하는게 좋다고 합니다.

@Property에만 의존하지말고 디스크립터를 사용 고려

이전 챕터에서는 property의 유용함에 대해서 말씀드렸지만, 사실 property의 문제점이 하나 있습니다. 바로 재사용성입니다. score 클래스의 _score 애트리뷰트를 property를 이용하여 getter와 setter를 만들 수 있었던 이유는 우리가 score라는 변수가 있음을 알고 있었기 때문입니다.

만약 모든 변수에 대해서 이러한 getter / setter를 설정한다면 애트리뷰트를 늘리는 것에 상당한 부담을 느낄 것 같습니다. 뿐만아니라 코드도 길어지는 문제가 발생합니다.

Spring에서 Lombok을 이용해서 어노테이션 형태로 해결했던 것과 마찬가지로 파이썬또한 이러한 문제를 해결 할 수 있는 방안이 있습니다.

그 방안은 특정 애트리뷰트를 클래스로 묶은 뒤 __get__와 __set__을 정의하는 것입니다. 그 뒤 하나의 데이터 클래스로 묶어서 사용하면 편하게 구현 할 수 있습니다.

class name:
        
    def name_check(self,name):
        if len(name) > 10:
            raise ValueError("이름은 10글자를 넘길 수 없습니다.")
    
    def invalid_name_check(self,names):
        if len(names) != 2 or type(names) != list:
            raise ValueError("성과 이름에 공백을 두고 입력해주세요")
    
    def __get__(self, instance, instance_type):
        return f"{first_name} {last_name}"
    
    def __set__(self, instance, value):
        name = value.split(' ')
        self.invalid_name_check(name)
        self.name_check(' '.join(name))
        self._first_name = name[0]
        self._last_name = name[1]
        

class person:
    name = name()
    math_score = score()
    korean_score = score()
    english_score = score()
    def __init__(self, first_name, last_name):
        self.name = f"{first_name} {last_name}"
        
    
my_stats = person("lion2","me")
my_stats.math_score = 100
print(my_stats.math_score)
my_stats.name = "lion2 Mk2"
print(my_stats.name)
my_stats.name = "lion2me"

#100
#lion2 Mk2
#ValueError: 성과 이름에 공백을 두고 입력해주세요

이런 식으로 사용하면 될 것 같습니다.

지연 계산 애트리뷰트가 필요하면 __getattr__ __setattr__ __getattribute__ 를 고려

__getattr__ 메서드는 해당 객체의 인스턴스 딕셔너리에서 찾을 수 없는 애트리뷰트에 접근 할 때마다 호출됩니다. 만약 가져오고자 하는 애트리뷰트가 이미 존재하면 여러 번 동작하지 않습니다.

__getattribute__ 메서드는 해당 객체의 어떠한 애트리뷰트에 접근을 하든 호출됩니다. 존재하는 애트리뷰트를 불러 올 때도 호출되기 때문에 return에 자신이 속한 클래스의 애트리뷰트에 접근하면 무한루프의 가능성이 높습니다. 부모 클래스의 속성에 넣는 방법으로 예방 할 수 있습니다.

__setattr__ 메서드는 해당 객체의 어떠한 애트리뷰트에 값을 변경 할 때 항상 호출됩니다.

이 부분은 그냥 예제로 가져온 코드를 보여주는게 좋을 것 같습니다.

class A(object): 
     
    def __init__(self): 
        self.__v = bytearray(sorted("Hi".encode(), reverse=True)).decode() 
     
    @property 
    def value(self): 
        return self.__v 
     
    @value.setter 
    def value(self, value): 
        self.__v = value 
         
    def set(self, value):
        print("A's set")
        self.__v = value 
     
    def get(self): 
        return self.__v 
 
 
class Proxy(object): 
     
    def __init__(self, o): 
        object.__setattr__(self, "_o", o) 
        self.__v = "Proxy"
        self.tmp = "tmp"
     
    @property     
    def proxy(self): 
        return self.__v 
     
    @proxy.setter 
    def proxy(self, value): 
        self.__v = value 
     
    def __getattr__(self, name): 
        print("getter action")
        return getattr(object.__getattribute__(self, "_o"), name) 
     
    def __setattr__(self, name, value): 
        print("setter action")
        if name == "proxy": 
            return object.__setattr__(self, name, value) 
         
        return setattr(object.__getattribute__(self, "_o"), name, value) 
 
 
def main(): 
    print("0. initialize class")
    a = A() 
    p = Proxy(a) 
    print()
    print("1. p.value : %s" % p.value)     
    s = "Hi" 
    print()
    print("2. p.value = %s" % s) 
    print()
    print("3. p.value's set")
    p.value = s 
    print()
    print("4. p.value : %s" % p.value) 
    print()
     
    print("5. p.proxy : %s" % p.proxy) 
    print()
    s = bytes(sorted("Proxy".encode(), reverse=True)).decode()
    print()
    print("6. p.proxy = %s" % s) 
    print()
    print("7. s into p.proxy")
    p.proxy = s 
    print()
    print("7. p.proxy : %s" % p.proxy) 
    print()

    print("8. p.proxy : %s" % p.tmp) 
    print()

    print(f"9. {p.__dict__['_o']}")
    
    print()
    p.test = 10
    print(f"10. {p.test}")
    
if __name__ == "__main__": 
    main()
    
# 0. initialize class
# setter action
# setter action

# getter action
# 1. p.value : iH

# 2. p.value = Hi

# 3. p.value's set
# setter action

# getter action
# 4. p.value : Hi

# getter action
# 5. p.proxy : Proxy


# 6. p.proxy = yxroP

# 7. s into p.proxy
# setter action
# setter action

# getter action
# 7. p.proxy : yxroP

# getter action
# 8. p.proxy : tmp

# 9. <__main__.A object at 0x7fde78180760>

# setter action
# getter action
# 10. 10

__init_subclass__를 이용

Meta 클래스를 작성하면 클래스를 정의하는 순간 실행되는 로직을 작성 할 수 있습니다. 즉 어떠한 클래스를 import 하는 시점부터 유효성을 검증하고 예외 처리를 할 수 있게 됩니다.

class Meta(type):
    def __new__(meta, name, bases, class_dict):
        print(f"* 실행: {name}의 메타 {meta}.__new__")
        print("기반 클래스 : ",bases)
        print(class_dict)
        return type.__new__(meta,name,bases,class_dict)
    
class MyClass(metaclass=Meta):
    stuff = 123
    def foo(self):
        pass
    
class MySubClass(MyClass):
    other = 567
    def bar(self):
        pass
        
# * 실행: MyClass의 메타 <class '__main__.Meta'>.__new__
# 기반 클래스 :  ()
# {'__module__': '__main__', '__qualname__': 'MyClass',  'stuff': 123, 'foo': <function MyClass.foo at  0x7fa6aa5cf1c0>}
# * 실행: MySubClass의 메타 <class '__main__.Meta'>.__new__
# 기반 클래스 :  (<class '__main__.MyClass'>,)
# {'__module__': '__main__', '__qualname__':  'MySubClass', 'other': 567, 'bar': <function  MySubClass.bar at 0x7fa6aa5ce170>}

Meta에서 Exception처리를 하면 조건에 맞지 않은 클래스는 정의조차 못하게 할 수 있습니다.

이 방식은 파이썬 표준 메타클래스 정의 방식이지만, 여러 개의 메타클래스를 정의 할 수 없고, 비록 하더라도 좋지않은 코드가 나올 가능성이 높습니다.

그래서 파이썬 3.6 이상에서는 __init_subclass__ 사용 권장합니다.

__init_subclass__ 를 사용하면 일반적으로 사용하는 클래스 상속으로 여러 메타클래스를 사용 한 유효성 검사와 같은 작업을 할 수 있습니다.

사용방법 또한 metaclass를 별도로 지정하는게 아니라 상속 받으려는 클래스의 __init_subclass__를 사용하기 때문에 이해하기도 쉽습니다.

명심해야 하는 부분은 메타클래스의 검증은 클래스의 모든 본문이 끝난 뒤에 호출된다는 점입니다.

__init_subclass__를 이용해서 확장

어느정도 알겠지만, 굳이? 라는 생각이 듭니다.

__set_name__을 이용해서 클래스의 프로퍼티 이름 처리 가능

  1. Field 클래스의 init으로도 가능 하지만 중복이 있습니다.
  2. 메타클래스를 이용해서 클래스 정의 시점에서 동작을 수행 할 수 있습니다.
  3. Field 클래스에서 __set_name__을 이용하면 별도의 수행 없이 프로퍼티를 처리 가능합니다.

이 부분은 특정 클래스의 애트리뷰트의 속성 값 ( 정확히는 객체의 __dict__의 키 값)을 다룰 때 유용할 것 같습니다.

클래스 데코레이터를 이용해서 모든 메서드나 애트리뷰트를 변경

이 부분은 많은 사용이 될 수 있는 부분이기 때문에 적어보겠습니다.

일반적으로 어떤 동작을 수행 할 때 특정한 로직을 추가하기 위해서 우리는 데코레이터를 사용합니다. 예를들면 어떠한 딕셔너리를 리턴하는 메소드를 RDB에 입력하기 위해 Parsing하는 데코레이터를 추가해서 편하게 SQL문으로 만들어주거나 할 수 있죠.

일반적으로 함수를 데코레이터로 사용했었지만 클래스도 데코레이터로 사용 할 수 있습니다.

이전에 만들었던 __init_subclass__를 정의 한 클래스를 데코레이터로 추가하면 이전까지의 설정들을 쉽게 사용 할 수 있습니다.

책의 예제에서는 tracking하는 로직을 모든 메서드에 추가했습니다.