영찬 블로그
About

The Fun Of Reinvention
파이썬3.6 타입 어노테이션으로 타입 검증 프레임워크 만들기
2017-07-02

이 글은 The Fun Of Reinvention 를 보고 정리한 글입니다.

1. 어노테이션

파이썬에서 최대공약수를 구하는 함수를 작성하면 다음과 같다.

def gcd(a, b):
    while b:
        a, b = b, a % b
    return a
>>> gcd(27, 36)
9

알다시피, 파이썬은 동적 타입 언어이다. 우리는 a, b는 당연히 정수이고 사용자도 그렇게 사용할거야 라고 생각하고 작성한 코드는 예상하지 못한 결과를 낳을 수 있다.

>>> gcd(2.7, 3.6)
4.440892098500626e-16
>>> gcd('12', '8')
TypeError: not all arguments converted during string formatting

다행히도 방법이 있다. 파이썬3.6에는 타입 어노테이션이란게 있다!

def gcd(a: int, b: int):
    ...

하지만 타입 어노테이션이라는 이름 그대로, 타입 어노테이션은 어노테이션(주석)에 불과하다. 결과값에 아무런 영향을 미치지 않는다. 그래서 우리는 a와 b는 int가 아니면 에러를 일으키게끔 다음과 같이 assert 구문을 추가했다.

def gcd(a, b):
    assert isinstance(a, int), 'Expected int'
    assert isinstance(b, int), 'Expected int'

    while b:
        ...

이 코드를 좀 더 파이썬3.6 스럽게 하기 위해, 타입 검증을 위한 프레임워크를 만들어볼 것이다.

class Contract:
    @classmethod
    def check(cls, value):
        pass

class Integer(Contract):
    @classmethod
    def check(cls, value):
        assert isinstance(value, int), 'Expected int'
>>> Integer.check(1)
>>> Integer.check(1.5)
AssertionError: Expected int

Integer 말고도 Float, String 등의 클래스들을 만들어 간단하게 타입 검증을 할 수 있을 것이다. 하지만 단순히 반복되는 코드에 불편함을 느낀다.

class Contract:
    @classmethod
    def check(cls, value):
        pass

class Typed(Contract):
    @classmethod
    def check(cls, value):
        assert isinstance(value, cls.type), f'Expected {cls.type}'
        super().check(value)

class Integer(Typed):
    type = int

class Float(Typed):
    type = float

class String(Typed):
    type = str

한결 간단해졌다! 여기서 나오는 f'a = {a}'PEP 498의 F-String 이라는 문법이다.

여기서 멈추지 않고 다른 검증 클래스도 만들어보자. 함수 gcd에서 a와 b는 자연수여야 하므로 0보다 커야한다.

class Positive(Contract):
    @classmethod
    def check(cls, value):
        assert value > 0, 'Must be > 0'
        super().check(value)

그렇다면 이 검증 클래스들을 사용하여 gcd 함수를 다시 작성해보자.

def gcd(a, b):
    Integer.check(a)
    Positive.check(a)
    Integer.check(b)
    Positive.check(b)

    while b:
        a, b = b, a % b
    return a
>>> gcd(27, 36)
9
>>> gcd("wow", "such")
AssertionError: Expected <class 'int'>
>>> gcd(-1, 1)
AssertionError: Must be > 0

양수의 정수임을 확인하는 PositiveInteger 클래스를 만들자.

class PositiveInteger(Integer, Positive):
    pass

PositiveInteger.check 는 먼저 Typed.check 를 호출한다. 그리고 거기서 super().check 를 호출하는데, 이게 Positive.check 를 호출하게 된다. 결론적으로 작동이 잘 된다! 이해가 잘 되지 않는 사람들은 여기를 참고하자.

여기까지의 진행 상황을 gist에 올려두었다. 볼 사람은 보던가!

그렇다면 gcd를 PositiveInteger를 사용하여 다시 작성하면 다음과 같이 된다.

def gcd(a, b):
    PositiveInteger.check(a)
    PositiveInteger.check(b)

    while b:
        ...

여기서, 처음 사용했던 타입 어노테이션을 사용할 수는 없을까? 하고 생각한다.

def gcd(a: PositiveInteger, b: PositiveInteger):
    ...

당연한 소리지만, 타입도 잘못되었고 아무렇게도 작동하지 않는다. 그렇다면 이를 작동하게 만들어보자. 메서드의 매개변수의 어노테이션을 가져오는 어트리뷰트는 __annotations__ 이다.

>>> gcd.__annotations__
{'a': <class '__main__.PositiveInteger'>, 'b': <class '__main__.PositiveInteger'>}

그리고 여기서부터 흑마☆법이 시작된다. inspect 모듈은 파이썬 오브젝트에 대한 정보를 가져올 수 있는 모듈이다. inspect.signature 함수는 callable 객체의 시그니쳐 정보를 가져온다. 직접 써보는게 백배 나으니까 파이썬 쉘 열고 직접 입력해보자.

>>> from inspect import signature
>>> signature(gcd)
<Signature (a:__main__.PositiveInteger, b:__main__.PositiveInteger)>
>>> signature(gcd).bind(1, 4)
<BoundArguments (a=1, b=4)>
>>> signature(gcd).bind(1, 4).arguments
OrderedDict([('a', 1), ('b', 4)])

이쯤 왔으면 감이 올것이다. 우리는 어노테이션과 시그니쳐를 비교해, 각각의 경우를 검증할 것이다.

from functools import wraps
from inspect import signature

def checked(func):
    sig = signature(func)
    ann = func.__annotations__
    @wraps(func)
    def wrapper(*args, **kwargs):
        bound = sig.bind(*args, **kwargs)
        for name, val in bound.arguments.items():
            if name in ann:
                ann[name].check(val)
        return func(*args, **kwargs)
    return wrapper

우리가 할 것은, 매개변수에 타입 어노테이션을 적용한 함수 gcd에 checked 데커레이터를 설정하는 것 뿐이다.

@checked
def gcd(a: PositiveInteger, b: PositiveInteger):
    while b:
        a, b = b, a % b
    return a

실행 결과는 예상했던 대로이다.

>>> gcd(-11, 22)
AssertionError: Must be > 0

2. 클래스 어노테이션

이제 더 깊이 들어가볼 시간이다. 이제는 클래스를 살펴볼 것이다.

class Player:
    def __init__(self, name, x, y):
        self.name = name
        self.x = x
        self.y = y
    
    def left(self, dx):
        self.x -= dx
    
    def right(self, dx):
        self.x += dx

제대로 만든 최악의 게임 클래스가 된 것 같다. Player 클래스의 사용은 간단하다. 우리가 이상한 값을 넣지만 않는다면 말이다.

>>> p = Player('아드', 10, 2)
>>> p.x
10
>>> p.x = '하핳 받아랏!!'
>>> p.left(-5)
TypeError: unsupported operand type(s) for -=: 'str' and 'int'

이를 방지하기 위한 간단한 방법은 우리를 알고 있다. x의 getter/setter, 즉 프로퍼티를 만드는 것이다.

class Player:
    def __init__(self, name, x, y):
        self.name = name
        self.x = x
        self.y = y
    
    @property
    def x(self):
        return self._x
    
    @x.setter
    def x(self, value):
        Integer.check(value)
        self._x = value
>>> p = Player('아드', 0, 0)
>>> p.x = 10
>>> p.x = '멍츙'
AssertionError: Expected <class 'int'>

잘 작동한다. 하지만 일일히 프로퍼티를 만드는 것은 귀찮다. 우리는 더 간결하고 멋있는 방법을 찾을 것이다. 늘 그랬듯이.

일일히 프로퍼티를 만드는 대신 파이썬3.6의 기능들을 사용해 간결하게 하는 것이다! 일단 Contract 클래스에 __set____set_name__ 메서드를 오버라이드하고 코드를 다음과 같이 수정하자.

class Contract:
    def __set__(self, instance, value):
        self.check(value)
        instance.__dict__[self.name] = value

    def __set_name__(self, owner, name):
        self.name = name

    @classmethod
    def check(cls, value):
        pass

그리고 Player의 name이 빈칸이 되지 않아야 하니까 다음 두 검증 클래스를 추가하자. 이름 그대로, NonEmpty는 길이가 0보다 큰지 검사를 하고, NonEmptyString은 길이가 0보다 큰 문자열인지 검사한다.

class NonEmpty(Contract):
    @classmethod
    def check(cls, value):
        assert len(value) > 0, 'Must be nonempty'
        super().check(value)

class NonEmptyString(String, NonEmpty):
    pass

그리고 Player 클래스도 수정하자. 프로퍼티를 없애고 다음과 같이 말이다.

class Player:
    name = NonEmptyString()
    x = Integer()
    y = Integer()

    def __init__(self, name, x, y):
        self.name = name
        self.x = x
        self.y = y

놀랍게도, 이 코드는 다음과 같이 작동한다.

>>> p = Player('아드', 0, 0)
>>> p.name = 10
AssertionError: Expected <class 'str'>
>>> p.name = ''
AssertionError: Must be nonmpty

다음으로 넘어가기 전에, 이 코드가 어떻게 작동하는지 보고 가자. 자세한 설명은 안하고 코드가 어떻게 돌아가는지만 설명할테니까, 알아서 찾아보자...

먼저 __set____set_name__이 뭐하는 메서드인지 알아야 할 것 같다. 아는 사람이나 내 설명을 듣느니 구글에 물어보는게 낫겠어라고 생각하는 사람은 넘어가도 좋다.

class SetMe:
    def __set__(self, instance, value):
        print(f'you set {value}!')

    def __set_name__(self, owner, name):
        print(f'you set name {name}!')


class SetU:
    a = SetMe()

    def __init__(self):
        self.a = 'wow'

u = SetU()
u.a = 1

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

you set name a!
you set wow!
you set 1!

SetMe.__set_name_가 호출되는 지점은 a = SetMe() 이다. self에는 SetMe 오브젝트가, owner에는 SetMe클래스가, name에는 a가 들어간다.

그렇다면 SetMe.__set__는? 클래스의 안팎에서, 객체의 어트리뷰트값을 변경할때 어트리뷰트의 __set__가 호출 된다. 위 코드에서는 self.a = 'wow'u.a = 1에서, self에는 SetMe 오브젝트가, instance에는 SetU 오브젝트가, value에는 'wow', 1이 각각 들어가진다. __set_name__과 큰 차이점이라면 호출되는 위치가 클래스이냐 인스턴스이냐 인 것 같다.

그럼 원래로 돌아가, Contract 클래스를 볼까? 스크롤 올리기 귀찮은 사람들을 위해 내가 기꺼이 복사를 해왔다.

class Contract:
    def __set__(self, instance, value):
        self.check(value)
        instance.__dict__[self.name] = value

    def __set_name__(self, owner, name):
        self.name = name
    
    ...

class Player:
    name = NonEmptyString()
    x = Integer()
    y = Integer()

이제 다시 코드를 보면 이 코드가 어떻게 작동하는지 알 수 있을 것이다. x.name'x'이고, p.x = 'wow'Contract.__set__(p.x, p, 'wow')가 호출한다.

설명을 지지리도 못하는 내 초라한 설명이 끝났으니 본론으로 돌아가자. 1장에서 했듯이, 최대한 파이썬3.6 의 기능을 살려보는 것이다. 이번에는 클래스 어노테이션을 사용할 것이다.

class Player:
    name: NonEmptyString
    x: Integer
    y: Integer

어노테이션은 다음과 같이 확인할 수 있다.

>>> Player.__annotations__
{'name': <class 'NonEmptyString'>, 'x': <class 'Integer'>, 'y': <class 'Integer'>}

이제 이 코드를 아까처럼 작동하게 만들어 보자. 우리는 Player의 슈퍼 클래스로 Base 클래스를 만들 것이고, BasePlayer클래스의 각 어트리뷰트를 초기화 할때 약간의 조작(?)을 해줄 것이다.

class Base:
    @classmethod
    def __init_subclass__(cls):
        for name, val in cls.__annotations__.items():
            contract = val()
            contract.__set_name__(cls, name)
            setattr(cls, name, contract)

class Player(Base):
    ...

작동할까? 잘 작동한다.

>>> p = Player('아드', 0, 0)
>>> p.name = 12
AssertionError: Expected <class 'str'>

간단하게 __init_subclass__를 설명하자면, Player.__init__가 선언되기 전에 호출이 된다. 그래서는 Player의 클래스 어노테이션들에 대해 각각 객체를 만들어서 값을 설정해줘서 아까와 같은 결과가 된다. 멋진 신택스 슈거가 된 셈이다.

한발자국만 더 나가서, 단순히 값을 설정하기만 하는 생성자도 생략할 수 있다.

class Base:
    @classmethod
    def __init_subclass__(cls):
        ...
    
    def __init__(self, *args):
        ann = self.__annotations__
        assert len(ann) == len(args), f'Expected {len(ann)} arguments'
        for name, val in zip(ann, args):
            setattr(self, name, val)

    def __repr__(self):
        args = ', '.join(repr(getattr(self, name)) for name in self.__annotations__)
        return f'{type(self).__name__}({args})'

객체를 출력할 때 모든 값을 보여주게끔 만든 __repr__ 메서드는 덤이다. 이렇게 만들었으면 정말로 Player의 생성자를 없애고 테스트를 해보자.

class Player(Base):
    name: NonEmptyString
    x: Integer
    y: Integer

    def left(self, dx):
        self.x -= dx

    def right(self, dx):
        self.x += dx

엄청 간략해 졌다. 간단한 테스트를 해보면, 역시 잘 된다. 이렇게 잘 되도 될까 생각이 들 정도로 말이다.

>>> p = Player('아드', 0, 0)
>>> p
Player('아드', 0, 0)
>>> p.x
0
>>> p.x = '코드엑스'
AssertionError: Expected <class 'int'>

그럼 이제 우리가 봐야 할것은 Player.leftdx이다. 이는 왠지 자연수이어야 할 것 같고, 왜냐하면 내가 그렇다고 하니까, 그래서 이는 PositiveInteger로 꾸며져야 할 필요가 있다.

class Player(Base):
    ...
    def left(self, dx: PositiveInteger):
        self.x -= dx
    
    def right(self, dx: PositiveInteger):
        self.x += dx

하지만 아직 우리는 메서드들의 매개변수 어노테이션에 대해서는 손댄적이 없다. Base.__init_subclass__에서 해결해야 한다.

class Base:
    @classmethod
    def __init_subclass__(cls):
        for name, val in cls.__dict__.items():
            if callable(val):
                setattr(cls, name, checked(val))

        for name, val in cls.__annotations__.items():
            contract = val()
            contract.__set_name__(cls, name)
            setattr(cls, name, contract)

파이썬 데커레이터의 작동 방식을 안다면 바로 이해될 것이다.

@deco
def func:
    pass

def func:
    pass
func = deco(func)

와 같은 작동을 한다. 즉, Base.__init_subclass__는 클래스의 메서드들에 대해 자동으로 checked 데커레이트를 씌어준다.

잘 맞지 않는 내 감으로도 이 코드는 잘 작동할 것이라고 느껴지지만, 하지만 이 글을 읽는 멋쟁이들을 위해 간단한 결과를 보여주겠다. 우선 지금까지 작성한 코드이다.

class Base:
    @classmethod
    def __init_subclass__(cls):
        for name, val in cls.__dict__.items():
            if callable(val):
                setattr(cls, name, checked(val))

        for name, val in cls.__annotations__.items():
            contract = val()
            contract.__set_name__(cls, name)
            setattr(cls, name, contract)

    def __init__(self, *args):
        ann = self.__annotations__
        assert len(ann) == len(args), f'Expected {len(ann)} arguments'
        for name, val in zip(ann, args):
            setattr(self, name, val)

    def __repr__(self):
        args = ', '.join(repr(getattr(self, name)) for name in self.__annotations__)
        return f'{type(self).__name__}({args})'


class Player(Base):
    name: NonEmptyString
    x: Integer
    y: Integer

    def left(self, dx: PositiveInteger):
        self.x -= dx

    def right(self, dx: PositiveInteger):
        self.x += dx
>>> p = Player('아드', 0, 0)
>>> p
Player('아드', 0, 0)
>>> p.left(-1)
AssertionError: Must be > 0

3. 메타클래스

다음은 메타클래스에 관한 설명이다. 하지만 내 설명이 파이썬의 ㅍ도 모르는 사람들에게도 바로 팍팍 꽂히는 설명이 아니기 때문에 파이썬 문서를 참고하자. 다음과 같이 클래스를 선언할 때, 실제로 파이썬은 어떻게 작동할까?

class Player(Base):

는 다음과 같다.

type.__prepare__('Player', (Base,))

여기서 __prepare__는 클래스의 어트리뷰트와 메서드들을 키와 밸류로 가지는 딕셔너리를 리턴한다. 간단하게 이를 확인하는 예제 코드를 짜보자.

from collections import OrderedDict
dic = {}


class Meta(type):
    @classmethod
    def __prepare__(cls, *args):
        global dic
        dic = OrderedDict()
        return dic

class Base(metaclass=Meta):
    a = 'wow'
    def __init__(self):
        self.name = '아드'
        self.x = 0
        self.y = 42

print(dic)

실행 결과는 다음과 같다.

OrderedDict([('__module__', '__main__'), ('__qualname__', 'Base'), ('a', 'wow'), ('__init__', <function Base.__init__ at 0x011C51E0>)])

그러면 이를 반대로 이용해서, __prepare__에서 이미 값을 넣어준다면 클래스에서 선언하지 않은 오브젝트를 사용할 수 있지 않을까?

class Meta(type):
    @classmethod
    def __prepare__(cls, *args):
        def see_me():
            return '내가 보이니..?'
        return {'see_me': see_me}

class Base(metaclass=Meta):
    a = see_me()

print(Base.a)
내가 보이니..?

클래스 Base에서, 스코프때문에 알 수 없는 함수 see_me를 호출했지만 런타임 에러 없이 잘 출력되는 것을 볼 수 있다!

사실 이건 비밀인데, 메타클래스가 클래스를 선언하기 전에 __prepare__를 호출한다면, 클래스를 선언한 뒤에는 __new__를 호출한다. 정확히 말하면, Base = type.__new__(..)를 호출한다. __new__(meta, name, bases, methods)meta에 메타클래스 자신의 클래스가, name에는 생성된 클래스의 이름이, bases는 생성된 클래스의 슈퍼 클래스들이 튜플로, methods에는 __prepare__에서 리턴했고, 클래스의 선언중 값이 채워졌던 딕셔너리 값이 들어가진다.

class Meta(type):
    @classmethod
    def __prepare__(cls, *args):
        return {}

    def __new__(meta, name, bases, methods):
        print(f'name : "{name}", methods: {methods}')
        return super().__new__(meta, name, bases, methods)

class Base(metaclass=Meta):
    pass
name : "Base", methods: {'__module__': '__main__', '__qualname__': 'Base'}

이렇게 메타클래스를 이용해 클래스에 어트리뷰트를 추가하는 것을 알아보았다. 하지만 지금까지 우리가 한대로라면, 이전의 내가 보이니..?를 출력하는 코드에는 문제점이 있다.

class Meta(type):
    @classmethod
    def __prepare__(cls, *args):
        def see_me():
            return '내가 보이니..?'
        return {'see_me': see_me}

    def __new__(meta, name, bases, methods):
        return super().__new__(meta, name, bases, methods)


class Base(metaclass=Meta):
    pass

print(Base.see_me())
내가 보이니.?

바로 원하지 않았던 클래스 외부에서 어트리뷰트로 see_me에 접근할 수 있다는 것이다. 내가 원하는 것은 클래스 내부에서만 사용하는 것이었는데! 다양한 해결 방법이 있겠지만 우리는 collections.ChainMap이라는 콜렉션을 사용해 이 문제를 해결해보자.

from collections import ChainMap

dic = None

class Meta(type):
    @classmethod
    def __prepare__(cls, *args):
        def see_me():
            return '내가 보이니..?'
        global dic
        dic = ChainMap({}, {'see_me': see_me})
        return dic

    def __new__(meta, name, bases, methods):
        methods = methods.maps[0]
        return super().__new__(meta, name, bases, methods)

class Base(metaclass=Meta):
    a = see_me()

print(Base.a)
print('see_me' in Base.__dict__.keys())
내가 보이니..?
False

ChainMap은 여러개의 맵들을 가지는 콜렉션이다. 예제 코드를 보면 쉽게 이해가 갈 것이다.

>>> c = ChainMap({}, {'x': 0, 'y': 0})
>>> c['x']
0
>>> c['a'] = 42
>>> c
ChainMap({'a': 42}, {'x': 0, 'y': 0})
>>> c.maps[0]
{'a': 42}
>>> c['y']
0

그렇다면 이제 본론으로 돌아가자. 우리는 Player 클래스의 어트리뷰트와 어노테이션을 이용하여 생성자를 숨기는 작업을 했다. 이 때 어노테이션에 쓰이는 검사 클래스들, Contract를 상속하는 클래스들을 직접 import시켜줬어야 했다. 지금까지 살펴본 메타클래스를 사용하여 이 문제를 해결할 방법이 쉽게 떠올르지 않는다면 이 글을 다시 읽어보도록 하자.

먼저 Contract를 상속하는 클래스들을 가져와야 한다. 이는 Contract.__init_subclass__를 오버라이드하여 쉽게 해결할 수 있다.

_contracts = { }

class Contract:
    @classmethod
    def __init_subclass__(cls):
        _contracts[cls.__name__] = cls
>>> _contracts
{'Typed': <class 'Typed'>, 'Integer': <class 'Integer'>, 'Float': <class 'Float'>, 'String': <class 'String'>, 'Positive': <class 'Positive'>, 'PositiveInteger': <class 'PositiveInteger'>, 'NonEmpty': <class 'NonEmpty'>, 'NonEmptyString': <class 'NonEmptyString'>}

그다음은 Base클래스의 메타클래스가 될 BaseMeta클래스를 만들어주고 지금까지 했던 작업을 해주자.

class BaseMeta(type):
    @classmethod
    def __prepare__(cls, *args):
        return ChainMap({}, _contracts)

    def __new__(meta, name, bases, methods):
        methods = methods.maps[0]
        return super().__new__(meta, name, bases, methods)

class Base(metaclass=BaseMeata):
    ...

그렇다면 끝난 것이다! 새로운 파이썬 파일을 만들고, 이 모듈에서 Base 클래스만을 임포트해주고, 잘 작동하는지 확인해보자.

from contract import Base

class Player(Base):
    name: NonEmptyString
    x: Integer
    y: Integer

    def left(self, dx: PositiveInteger):
        self.x -= dx

    def right(self, dx: PositiveInteger):
        self.x += dx

p = Player('아드', 0, 0)
p.left(-1)
AssertionError: Must be > 0

억지스럽지만 마지막으로 하나만 더 해보자. left, right 메서드는 PositiveInteger 조건을 가지는 같은 이름을 가진 dx 라는 매개변수를 받는다. 이것을 클래스 바깥으로 끄집어내서 dx라는 이름을 가지는 매개변수는 PositiveInteger 조건을 가짐이 자명합니다- 라고 글로벌 변수차원에서 선언할 수 있게 만들어보자.

checked 에 조금의 코드만 추가하면 된다.

def checked(func):
    sig = signature(func)
    ann = ChainMap(
        func.__annotations__,
        func.__globals__.get('__annotations__'), {}
    )

    @wraps(func)
    def wrapper(*args, **kwargs):
        bound = sig.bind(*args, **kwargs)
        for name, val in bound.arguments.items():
            if name in ann:
                ann[name].check(val)
        return func(*args, **kwargs)
    return wrapper

그렇다면 바뀐 Player 파일은 다음과 같다.

from contract import Base, PositiveInteger

dx: PositiveInteger

class Player(Base):
    name: NonEmptyString
    x: Integer
    y: Integer

    def left(self, dx):
        self.x -= dx

    def right(self, dx):
        self.x += dx

p = Player('아드', 0, 0)
p.left(-1)
AssertionError: Must be > 0

짜잔! 최종 소스코드는 gist에서 확인할 수 있습니다. 긴 글 읽어주셔서 감사합니다.

©20chan 2024, Built with Gatsby