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

    들어가기 전

     


    요약

    • Hook 등에 함수를 전달해야할 때, 상태없는 함수의 경우는 함수를 정의해서 사용하는 것이 좋음.
    • 만약 클로저를 이용해 상태있는 함수를 사용해야 한다면, 함수 대신 클래스를 정의해서 사용하는 것이 좋음. (가독성 문제)(
    • 이런 용도로 사용되는 클래스는 '__call__()'을 구현한 Callable 객체를 전달하는 것이 가독성이 좋음. 
    • 클래스의 __call__() 메서드를 구현하면 인스턴스를 호출할 수 있음. 

     


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

    파이썬 내장 메서드 중에서는 내장 메서드의 동작을 변경하도록 함수를 전달할 수 있는데, 이런 함수를 Hook이라고 한다. 예를 들어 내장 메서드 sort()에는 key라는 파라메터에 Hook 함수를 전달할 수 있다. 아래 코드의 경우 'len' 함수를 Hook으로 전달해서 문자열의 길이 순으로 정렬되도록 만든다. 

    my_list = ['a', 'aa', '123', '1', '124123']
    my_list.sort(key=len)
    print(my_list)

    다른 언어에서는 이런 Hook 제공을 위해 인터페이스(Java의 Interface)를 설계(타입 안정성) 해야하지만, 덕타이핑을 지원하는 파이썬은 원하는 함수를 Hook으로 전달하기만 하면 된다. 기본적으로 상태가 없는 함수를 Hook으로 전달하는 경우라면 함수를 사용하는 것이 좋은 선택지가 된다. 

    def hello(my_dict):
        state = 0 
        def real_func():
            nonlocal state
            state +=1
        
        for k,v in my_dict.items():
            real_func()
            print(k, v)

    하지만 클로저를 이용해 상태가 있는 함수를 Hook으로 전달하는 경우에는 가독성이 문제가 될 수 있다. 이렇게 클로저를 이용하는 함수가 있을 때, nonlocal 변수인 state에 대한 내용을 개발자는 계속 기억을 해야하고 코드의 Depth도 깊어진다. hello()라는 함수가 충분히 짧은 경우는 문제가 되지 않을 수 있지만, hello() 함수가 충분히 길어지면 이해하기 어려워진다. 

    상태가 필요한 함수를 Hook 등으로 전달해야하는 경우는 가독성을 위해 '클래스'를 사용하는 것이 더 좋다. 또한 '클래스'를 이용하는 의미를 더 명확하게 하기 위해 '__call__()' 메서드를 구현한 Callable 객체로 만들어주는 것이 더 좋다. 아래 예시에서 더 자세히 살펴본다. 


    코드 살펴보기 

    from collections import defaultdict
    
    def log_missing():
        print('키 추가됨')
        return 0
    
    
    current = {'초록': 12, '파랑': 3}
    increments = [
        ('빨강', 5),
        ('파란', 17),
        ('주황', 9)
    ]
    
    result = defaultdict(log_missing, current)
    print(f'이전 : {dict(result)}')
    for key, amount in increments:
        result[key] += amount
    print(f'이후 : {dict(result)}')

    위 코드는 다음을 수행한다.

    1. result라는 딕셔너리에 Key, Value를 추가한다.
    2. Key가 없을 때, log_missing 함수를 실행해서 기본값을 0으로 설정한다. 그리고 '키 추가됨'을 출력한다. 

    defaultdict()에서 log_missing은 hook을 위한 함수로 전달되었다. 그리고 log_missing 함수는 상태가 없는 함수이기 때문에 이렇게 사용해도 무방하다. 

    from collections import defaultdict
    
    
    def increment_with_report(current, increments):
        add_count = 0
    
        def missing():
            nonlocal add_count
            add_count += 1
            return 0
    
        result = defaultdict(missing, current)
        print(f'이전 : {dict(result)}')
        for key, amount in increments:
            result[key] += amount
        print(f'이후 : {dict(result)}')
    
    
    current = {'초록': 12, '파랑': 3}
    increments = [
        ('빨강', 5),
        ('파란', 17),
        ('주황', 9)
    ]
    increment_with_report(current, increments)

    그런데 missing() 함수처럼 Key를 얼마나 누락했는지 add_count라는 변수에 기록하고 싶은 경우가 있을 수도 있다. 이 때, missing 함수는 클로저로 add_count라는 변수를 가지고 있다. 즉, missing 함수는 상태를 가진 함수가 된다. 그런데 클로저를 사용하면 함수 내의 함수를 선언하는 형태가 되기 때문에 읽기 어렵다. 굳이 상태있는 함수를 사용해야하는 경우라면, 클래스를 이용해 명시적으로 상태를 보관하는 것이 좋다.

    from collections import defaultdict
    
    class CounterMissing:
    
        def __init__(self):
            self.count = 0
    
        def missing(self):
            self.count += 1
            return 0
    
    current = {'초록': 12, '파랑': 3}
    increments = [
        ('빨강', 5),
        ('파란', 17),
        ('주황', 9)
    ]
    
    counter = CounterMissing()
    result = defaultdict(counter.missing, current)
    print(f'이전 : {dict(result)}')
    for key, amount in increments:
        result[key] += amount
    print(f'이후 : {dict(result)}')

    missing에서 클로저로 add_count 상태를 유지하던 것을 CounterMissing 클래스에 self.count라는 인스턴스 변수로 처리했다. 클래스를 사용하면서 클로저를 사용하는 함수에 비해 Depth가 줄어들어 읽기 편해졌다.

    그러나 한 가지 단점은 counter.missing()이라는 함수가 실제 사용되는 예시를 보기 전까지 이 클래스가 언제 사용되는지에 대해서 유추하기 어렵다. 이 부분을 해결하기 위해서 인스턴스를 Callable 하게 만들어준다. 그렇게 하면 클래스의 사용 시점이 좀 더 명확하게 전달된다.

    from collections import defaultdict
    
    class CounterMissing:
    
        def __init__(self):
            self.count = 0
    
        def __call__(self, *args, **kwargs):
            self.count += 1
            return 0
    
    
    current = {'초록': 12, '파랑': 3}
    increments = [
        ('빨강', 5),
        ('파란', 17),
        ('주황', 9)
    ]
    
    counter = CounterMissing()
    result = defaultdict(counter, current)
    print(f'이전 : {dict(result)}')
    for key, amount in increments:
        result[key] += amount
    print(f'이후 : {dict(result)}')

    __call__() 메서드를 구현한 클래스의 인스턴스는 callable 하게 된다. 이전 코드에서는 counter.missing 메서드를 Hook으로 전달했었지만, 이제는 counter 인스턴스를 전달하면 된다. counter 인스턴스를 counter()로 호출하게 되면 __call__() 메서드가 실행된다. 그리고 이전에 상태를 업데이트 하던 행동은 __call__() 메서드 내에서 수행된다. 이를 통해 가독성을 얻고, 코드를 좀 더 간결하게 작성할 수 있었다. 

    댓글

    Designed by JB FACTORY