Djnago, Pandas 등 Python의 프레임워크/라이브러리릉 이용하다보면, 다음과 같은 패턴을 쉽게 접할 수 있을 것이다.
Table.objects.filter(id__in=id_include_list).exclude(id__in=id_exclude_list).sort_by('id')
df.drop_duplicates(subset='name').sort_values(by='id').reset_index(drop=True)
이렇듯 method가 객체를 반환하여 여러 method를 연쇄적으로 이어 동작시킬 수 있는 패턴을
메서드 체이닝(method chaining)이라 한다.
메서드 체이닝을 통해 코드가 불필요하게 길어지는 것을 방지할 수 있으며,
개발 간에 객체에 대한 관리를 더욱 용이하게 해준다는 장점이 있다.
파이썬 개발을 하다보면, 이미 생성되어있는 프레임워크/라이브러리에서 메소드 체이닝을 구현하는 것이 아닌
직접 클래스를 정의하고 메소드 체이닝을 구현해야하는 경우가 생긴다.
필자의 경우에도, 사내 데이터 분석 파이프라인을 개발하던 중 그러한 경우가 발생하였다.
이미 전처리가 완료된 하나의 데이터프레임에 대하여, 여러번 불러와 다양한 분석 지표를 구현하는데에 써야 하는 상황이었다.
이에 판다스를 통해 전처리한 데이터를 하나의 객체로 관리하여
같은 데이터를 통해 다양한 지표에 대한 여러 차례의 분석을 실시하고자
메소드 체이닝을 구현하여 쓰게 되었다.
간단한 예시를 통하여 메소드 체이닝을 구현해보고자 한다.
class Foo:
def __init__(self, value, *args, **kwargs):
self.value = value
def __repr__(self):
return str(self.value)
a = Foo(0)
print(a)
> 0
해당 코드에서 주의해야할 점은, a를 출력한 결과가 0이 나왔다고 해서
해당 값의 타입이 본질적으로 string 혹은 int 형식은 아니라는 것이다.
a를 출력했을때 보이는 0이라는 값은
Foo 클래스를 통해 선언된 객체를 repr을 통해 value의 값일 뿐이다.
print(isinstance(a,str))
print(isinstance(a,int))
print(isinstance(a,Foo))
print(type(a))
> False
> False
> True
> <class '__main__.Foo'>
이제 Foo 클래스 내에 다음과 같은 메소드를 추가해보자.
class Foo:
def __init__(self, value, *args, **kwargs):
self.value = value
def __repr__(self):
return str(self.value)
def add(self, num, *args, **kwargs):
self.value += num
def minus(self, num, *args, **kwargs):
self.value -= num
a=Foo(1)
print(a)
a.add(2)
print(a)
a.minus(3)
print(a)
> 1
> 3
> 0
이제 추가한 add, minus 메소드를 통해 클래스 내의 인스턴스 value에 대한 값의 조작이 가능해졌다.
하지만 아직 메소드 체이닝이 가능해진 것은 아니다.
a = Foo(1)
a.add(2).minus(3)
print(a)
---------------------------------------------------------------------------
AttributeError Traceback (most recent call last)
Input In [411], in <cell line: 2>()
1 a=Foo(1)
----> 2 a.add(2).minus(3)
3 print(a)
AttributeError: 'NoneType' object has no attribute 'minus'
add, minus 메소드는 단지 value 값만을 조작할 뿐 그 어떠한 결과를 반환하지 않기 때문이다.
다음과 같이 표현한다면 메소드 체이닝이 가능해진다.
class Foo:
def __init__(self, value, *args, **kwargs):
self.value = value
def __repr__(self):
return str(self.value)
def add(self, num, *args, **kwargs):
self.value += num
return self
def minus(self, num, *args, **kwargs):
self.value -= num
return self
a = Foo(1)
a.add(2).minus(3)
print(a)
> 0
하지만 이와 같은 구현에는 아쉬운 점이 한가지가 있다.
add, minus 메소드의 결과가 자기 자신 자체를 반환해버리기때문에,
한 번의 메소드 사용이 해당 값을 영구적으로 바꾸어버린다는 것이다.
장고, 판다스를 사용할 때에는 일반적으로 해당 결과가 객체가 가진 값을 영원히 바꾸지 않는다.
위의 결과는 마치 판다스에서 아규먼트로 (inplace=True)를 사용한 것과 같은 결과가 나오게 한다.
class Foo:
def __init__(self, value, *args, **kwargs):
self.value = value
def __repr__(self):
return str(self.value)
def add(self, num, *args, **kwargs):
return Foo(self.value + num)
def minus(self, num, *args, **kwargs):
return Foo(self.value - num)
a = Foo(1)
a.add(2).minus(3)
print(a)
print(a.add(2).minus(3))
a = a.add(2).minus(3)
print(a)
> 1
> 0
> 0
이제 더이상 한 번의 메소드가 객체의 값을 직접적으로 바꾸지 않는다.
메소드가 클래스를 그 자체로 반환하는 것이 아니라
클래스에 해당 값을 넣어서, 재귀적으로 반환하기 때문이다.
a = Foo(1)를 선언한 후
a.add(2)를 하는 것은 Foo(1+2)의 값을 반환하지만,
그 자체로 a가 나타내는 value의 값을 직접 조작하지는 않는다.
class Foo:
def __init__(self, value, *args, **kwargs):
self.value = value
def __repr__(self):
return str(self.value)
def add(self, num, inplace=False, *args, **kwargs):
if inplace:
self.value += num
return self
return Foo(self.value + num)
def minus(self, num, inplace=False, *args, **kwargs):
if inplace:
self.value -= num
return self
return Foo(self.value - num)
a = Foo(1)
print(a,id(a))
a = a.add(2).minus(3)
print(a,id(a))
> 1 4867638560
> 0 4867638176
a=Foo(1)
print(a,id(a))
a.add(2,inplace=True).minus(3,inplace=True)
print(a,id(a))
# a = a.add(2,inplace=True).minus(3,inplace=True) 또한 같은 값과 같은 id를 반환한다.
> 1 4867639664
> 0 4867639664
위와 같은 방법을 통해서 판다스의 inplace 아규먼트와도 같은 기능을 구현해볼 수 있다.
inplace 옵션의 True/False의 또다른 차이로, 메모리에 새롭게 값을 할당하느냐 혹은 기존의 객체 값을 그대로 수정하느냐에서도
찾아볼 수 있다.
class Foo:
def __init__(self, value, *args, **kwargs):
self.value = value
def __repr__(self):
return str(self.value)
def _replace(self,*args,**kwargs):
if kwargs['inplace']:
self.value = kwargs['result']
return self
else:
return Foo(kwargs['result'])
def add(self, num, inplace=False, *args, **kwargs):
result = self.value + num
return self._replace(result=result, inplace=inplace)
def minus(self, num, inplace=False, *args, **kwargs):
result = self.value - num
return self._replace(result=result, inplace=inplace)
a=Foo(1)
print(a,id(a))
a.add(2,inplace=False).minus(3,inplace=False)
print(a,id(a))
a=Foo(1)
print(a,id(a))
a=a.add(2,inplace=False).minus(3,inplace=False)
print(a,id(a))
a=Foo(1)
print(a,id(a))
a.add(2,inplace=True).minus(3,inplace=True)
print(a,id(a))
> 1 4637679680
> 1 4637679680
> 1 4637679776
> 0 4637679728
> 1 4637679776
> 0 4637679776
약간 다듬어본다면 위와 같은 표현을 할 수도 있을 것 같다.
다만 inplace 구현의 경우에는 특이 경우가 아니라면 구현을 생략해도 될 것 같다.
판다스의 컨트리뷰터들 또한 inplace=True 옵션의 사용을 지양할 것을 권고하고 있다.
실제로 속도, 메모리 상에서 유의미한 수준의 차이를 보이지 않는다고 한다.
뿐만아니라, 한 번의 메소드 체이닝 안에서 inplace 옵션을 True/False를 번갈아 쓰게 된다면
의도치 않은 잘못된 결과를 반환하게 될 수도 있다.
아무튼 내 경우에는,
이 메소드체이닝 기법을 통해
판다스를 통한 데이터 분석 파이프라인 작성 시에
같은 데이터에 대한 불필요한 코드의 중복을 줄일 수 있었다.
더불어 데이터프레임 하나를 한 개의 객체로 관리함으로써
필요한 지표에 따라 수차례의 분석을 가능하게 할 수 있었다.
ps
class Foo:
"""
Class for managing certain Integer value
"""
def __init__(self,
value: int,
*args,
**kwargs):
self.value: int = value
def __repr__(self):
return str(self.value)
def _get_result(self,
*args,
**kwargs) -> int:
if kwargs['inplace']:
self.value = kwargs['result']
return self
else:
return Foo(kwargs['result'])
def add(self,
num: int,
inplace: bool = False,
*args,
**kwargs) -> int:
"""
Add num value
"""
result: int = self.value + num
return self._get_result(
result=result,
inplace=inplace
)
def minus(self,
num: int,
inplace: bool = False,
*args,
**kwargs) -> int:
"""
Minus num value
"""
result: int = self.value - num
return self._get_result(
result=result,
inplace=inplace
)
def multiply(self,
num: int,
inplace: bool = False,
*args,
**kwargs) -> int:
"""
Multiply num value
"""
result: int = self.value * num
return self._get_result(
result=result,
inplace=inplace
)
def power(self,
num: int,
inplace: bool = False,
*args,
**kwargs) -> int:
"""
Get 'num'th power of value
"""
result: int = self.value ** num
return self._get_result(
result=result,
inplace=inplace
)
판다스의 컨벤션에 의하면, 메소드 체이닝은 다음과 같이 보기 좋게 표현될 수 있다.
a = (
Foo(1)
.add(5)
.multiply(3)
.minus(2)
.power(2)
)
print(a)
> 256