파이썬 코딩의 기술 - 38

JinWooHyun·2021년 7월 14일
0

간단한 인터페이스의 경우 클래스 대신 함수를 받아라

파이썬 내장 API 중 상당수는 함수를 전달해서 동작을 원하는 대로 바꿀 수 있게 해준다. API가 실행되는 과정에서 전달한 함수를 실행하는 경우, 이 함수를 훅(hook) 이라고 부른다.

예를 들어 sort 메서드는 정렬 시 각 인덱스에 대응하는 비교 값을 결정하는 선택적인 key 인자(훅)를 받을 수 있다.

names = ['소크라테스', '아르키메데스', '플라톤', '아리스토텔레스']
names.sort(key=len)
print(names)

>>>
['플라톤', '소크라테스', '아르키메데스', '아리스토텔레스']

훅을 추상 클래스(abstract class)를 통해 정의해야 하는 언어도 있지만, 파이썬에서는 단순히 인자와 반환 값이 잘 정의된, 상태가 없는 함수를 훅으로 사용하는 경우가 많다. 또한 파이썬은 함수를 일급 시민 객체(first-class citizen)로 취급하기 때문에 함수를 훅으로 사용할 수 있다.

일급 시민 객체
아무런 제약 없이 사용할 수 있는 데이터 값. 일반적으로 함수에 인자를 넘길 수 있고, 변수나 데이터 구조에 저장할 수 있으며, 함수에서 반환할 수 있고, 동등성을 검사할 수 있는 값을 말한다.

예를 들어 defaultdict 클래스의 동작을 사용자 정의하고 싶다면, defaultdict에는 딕셔너리 안에 없는 키에 접근할 경우 호출되는 인자가 없는 함수를 전달할 수 있다.

def log_missing():
    print('키 추가됨')
    return 0
    
from collections import defaultdict

current = {'초록':12, '파랑':3}
increments = [
    ('빨강',5),
    ('파랑',17),
    ('주황',9),
]
result = defaultdict(log_missing, current)
print('이전:', dict(result))
for key, amount in increments:
    result[key] += amount
print('이후', dict(result))

>>>
이전: {'초록':12, '파랑':3}
키 추가됨
키 추가됨
이후: {'초록':12, '파랑':20, '빨강':5, '주황':9}

log_missing과 같은 함수를 사용할 수 있으면 정해진 동작과 부수 효과(side effect)를 분리할 수 있기 때문에 API를 더 쉽게 만들 수 있다.

defaultdict에 전달하는 디폴트 값 훅이 존재하지 않는 키에 접근한 총횟수를 세고 싶다고 하자. 이런 기능은 상태가 있는 클로저를 사용해 구현할 수 있다.

def increment_with_report(current, increments):
    added_count = 0
    
    def missing():
        nonlocal added_count # 상태가 있는 클로저
        added_count += 1
        return 0
    
    result = defaultdict(missing, current)
    for key, amount in increments:
        result[key] += amount
    
    return result, added_count

defaultdictmissing 훅이 상태를 관리한다는 점을 알지 못하지만 함수 자체는 원하는 결과를 볼 수 있다.

하지만 상태를 다루기 위한 훅으로 클로저를 사용하면 상태가 없는 함수에 비해 읽고 이해하기 어렵다. 다른 접근 방법으로 추적하고 싶은 상태를 저장하는 작은 클래스를 정의하는 것이다.

class CountMissing:
    def __init__(self):
        self.added = 0
    
    def missing(self):
        self.added += 1
        return 0

파이썬에서는 일급 함수를 사용해 객체에 대한 CountMissing.missing 메서드를 직접 defaultdict의 디폴트 값 훅으로 전달할 수 있다.

counter = CountMissing()
result = defaultdict(counter.missing, current)
for key, amount in increments:
    result[key] += amount

하지만 클래스 자체만 놓고 보면 CountMissing 클래스의 목적이 무엇인지 분명히 알기 어렵다. 이런 경우를 더 명확히 표현하기 위해 클래스에 __call__ 특별 메서드를 정의할 수 있다.
__call__을 사용하면 객체를 함수처럼 호출할 수 있다. 그리고 __call__이 정의된 클래스의 인스턴스에 대해 callable 내장 함수를 호출하면, 다른 일반 함수나 메서드와 마찬가지로 True가 반환된다. 이런 방식으로 정의돼서 호출될 수 있는 모든 객체를 호출 가능(callable) 객체 라고 부른다.

class BetterCountMissing:
    def __init__(self):
        self.added = 0
    
    def __call__(self):
        self.added += 1
        return 0

counter = BetterCountMissing()
assert counter() == 0
assert callable(counter)
result = defaultdict(counter, current) # __call__에 의존함
for key, amount in inrements:
    result[key] += amount

assert counter.added == 2

__call__ 메서드는 (API 훅처럼) 함수가 인자로 쓰일 수 있는 부분에 이 클래스의 인스턴스를 사용할 수 있다는 사실을 나타낸다.

기억해야 할 내용

  • 파이썬의 여러 컴포넌트 사이에 간단한 인터페이스가 필요할 때는 클래스를 정의하고 인스턴스화하는 대신 간단히 함수를 사용할 수 있다.
  • 파이썬 함수나 메서드는 일급 시민이다. 따라서 함수나 함수 참조를 식에 사용할 수 있다.
  • __call__ 특별 메서드를 사용하면 클래스의 인스턴스인 객체를 일반 파이썬 함수처럼 호출할 수 있다.
  • 상태를 유지하기 위한 함수가 필요한 경우에는 상태가 있는 클로저를 정의하는 대신 __call__ 메서드가 있는 클래스를 정의할지 고려해보라.
profile
Unicorn Developer

0개의 댓글