파이썬 내장 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
defaultdict
는 missing
훅이 상태를 관리한다는 점을 알지 못하지만 함수 자체는 원하는 결과를 볼 수 있다.
하지만 상태를 다루기 위한 훅으로 클로저를 사용하면 상태가 없는 함수에 비해 읽고 이해하기 어렵다. 다른 접근 방법으로 추적하고 싶은 상태를 저장하는 작은 클래스를 정의하는 것이다.
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__
메서드가 있는 클래스를 정의할지 고려해보라.