본문 바로가기
프로그래밍/Python

[Python] 일급 함수 (Functions as First-Class Objects)

by 별준 2022. 3. 17.

References

  • Fluent Python

Contents

  • Functions as First-Class Objects
  • Higher-Order Functions
  • map, filter, reduce의 대안 방법
  • Anonymous Functions
  • Callable Types
  • Position-Only / Keyword-Only Parameter
  • operator, functools Module

파이썬의 함수는 일급 객체(first-class object) 입니다. 프로그래밍 언어 리서처들은 다음과 같은 작업들을 수행할 수 있는 프로그램 개체(entity)를 일급 객체라고 정의합니다.

  • 런타임에 생성할 수 있다
  • 변수나 데이터 구조체의 원소에 할당할 수 있다
  • 함수 인수로 전달할 수 있다
  • 함수의 결과로 반환할 수 있다

정수, 문자열 딕셔너리도 파이썬의 일급 객체입니다.

이번 포스팅에서는 함수를 객체로 처리하는 실용적인 방법과 영향에 대해서 살펴보도록 하겠습니다.

 


Treating a Function Like an Object

아래 코드는 파이썬 함수가 객체임을 보여줍니다. 여기서 우리는 함수를 생성하고, 호출하고, __doc__ 속성을 읽고, 함수 객체 자체가 function 클래스의 객체인지 확인할 수 있습니다.

주피터 노트북도 일종의 콘솔이기 때문에 함수를 '런타임'에 만들고 있는 것입니다. 여기서 __doc__는 함수 객체의 여러 속성 중 하나이며, 이는 객체의 help text를 생성하기 위해 사용됩니다. help(factorial) 명령을 입력하면 다음의 출력을 확인할 수 있습니다.

그리고 type(factorial)을 보면, factorial이 function 클래스의 객체라는 것을 확인할 수 있습니다.

 

다음 코드는 함수의 본질적인 'first-class'를 보여줍니다. 함수를 fact 변수에 할당하고, 이 변수명을 통해 함수를 호출합니다. 그리고 factorial을 map()의 인수로 전달할 수도 있습니다. map() 함수는 두 번째 인수의 연속된 요소(iterable 객체)에 첫 번째 인수(함수)를 적용한 결과를 가지는 iterable 객체를 반환합니다.

 


Higher-Order Functions

함수를 인수로 받거나, 함수를 결과로 리턴하는 함수를 고차 함수(higher-order function)이라고 합니다. 대표적으로 위에서 살펴본 map() 함수가 있습니다. sorted() 내장 함수도 일급 함수의 예입니다. sorted() 함수는 선택적인 key 인수로 함수를 전달받아 정렬할 각 항목에 적용합니다.

 

예를 들어 길이에 따라 단어 리스트를 정렬하려면 다음 예제 코드처럼 len 함수를 key 인수로 전달하면 됩니다.

인수를 하나 받는 함수는 모두 key 인수로 사용할 수 있습니다. 예를 들어, 라임을 위한 사전을 만들기 위해 단어 철자를 거꾸로 해서 정렬하면 도움이 됩니다. 아래 예제 코드는 리스트 안의 단어들은 전혀 바뀌지 않고, 오로지 거꾸로 된 철자가 정렬 기준으로 사용됩니다. 따라서 berry로 끝나는 단어들이 함께 나옵니다.

 

함수형 프로그래밍 패러다임에서 map, filter, reduce, apply 등의 고차 함수가 널리 알려져 있습니다. apply() 함수는 파이썬 2.3에서 폐기되었으며, 더 이상 필요하지 않아 파이썬 3에서 완전히 삭제되었습니다. 동적인 인수에 함수를 호출해야 할 때는 apply(fn, args, kwargs) 대신 fn(*args, **kwargs) 형태로 작성하면 됩니다.

 

map(), filter(), reduce()가 여전히 존재하지만 대부분의 경우 더 나은 방법들이 있습니다.

 

Modern Replacement for map, filter, aud reduce

이름이 다른 경우도 있지만, 함수형 언어는 모두 map(), filter(), reduce()를 제공합니다. map()과 filter() 함수는 여전히 파이썬 3에 내장되어 있지만, 리스트 컴프리헨션(list comprehension)과 제너레이터 표현식(generator expression)이 도입된 후에는 이 함수들의 중요성이 떨어졌습니다. 리스트 컴프리헨션이나 제너레이터 표현식이 map()과 filter()의 조합이 처리하는 작업을 표현할 수 있을 뿐만 아니라 가독성도 더 좋습니다. 다음 예제 코드를 살펴보겠습니다.

파이썬 3에서 map()과 filter()는 제너레이터를 반환하므로, 제너레이터 포현식이 이 함수들을 직접 대체합니다. 파이썬 2에서 내장되었던 reduce() 함수는 파이썬 3에서는 functools 모듈로 떨어져 나왔습니다. reduce()는 주로 합계를 구하기 위해 사용되는데, 2003년에 배포된 파이썬 2.3부터 내장 함수로 제공되는 sum() 사용하는 것이 가독성과 성능 면에서 훨씬 낫습니다. reduce()와 sum()의 사용법은 다음 코드에서 확인할 수 있습니다.

sum()과 reduce()는 연속된 항목에 어떤 연산을 적용해서, 이전 결과를 누적시키면서 일련의 값을 하나의 값으로 리덕션(reduction)한다는 공통점이 있습니다.

 

이외에 내장된 리덕션 함수는 all과 any가 있습니다.

  • all(iterable) : 모든 iterable이 참이라면 True를 반환. all([])는 True를 반환함
  • any(iterable) : iterable 중 하나라도 참이라면 True를 반환. any([])는 False를 반환함

 


Anonymous Functions

lambda 키워드는 파이썬 표현식 내에서 익명 함수(anonymous function)를 생성합니다.

그렇지만 파이썬의 단순한 구문은 람다 함수의 구현이 순수한 표현식으로만 구성되도록 제한합니다. 즉, 람다 바디에서는 할당문이나 while, try 등의 파이썬 문장을 사용할 수 없습니다. ':='를 사용하는 새로운 할당 표현식 구문을 필요하다면 사용할 수 있지만, 람다식이 매우 복잡해지고 읽기도 어려워지므로, def를 사용한 일반 함수로 리팩토링하는 것이 좋습니다.

 

익명 함수는 고차 함수의 인수 리스트 안에서 유용하게 사용됩니다. 예를 들어, 다음 코드는 위에서 살펴본 철자 역순으로 정렬하는 예제 코드를 reverse() 함수 대신 람다를 사용하도록 수정한 코드입니다.

고차 함수의 인수로 사용하는 방법 외에 익명 함수는 파이썬에서 거의 사용되지 않습니다. 구문 제한 때문에 복잡한 람다는 가독성이 떨어지고 사용하기도 까다롭습니다.

 

람다식은 단순히 편리하게 사용하는 구문일 뿐입니다. def문과 마찬가지로 람다식도 하나의 함수 객체를 만듭니다. 즉, 파이썬에서 제공하는 여러 Callable 객체 중 하나일 뿐입니다.

 


Nine Flavors of Callable Objects

호출 연산자(call operator)인 '()'는 사용자 정의 함수 이외의 다른 객체에도 적용할 수 있습니다. 호출할 수 있는 객체인지 알아보려면 callable() 내장 함수를 사용하면 됩니다. 파이썬의 Data Model documentation은 9가지 callable 타입을 나열하고 있습니다.

 

  • User-defined functions
    : def문이나 람다표현식으로 생성
  • Build-in functions
    : len()이나 time.strftime()처럼 C언어로 구현된 함수(for CPython)
  • Build-in methods
    : dict.get()처럼 C언어로 구현된 메소드
  • Methods
    : 클래스 바디에서 정의된 함수
  • Classes
    : 호출될 때 클래스는 자신의 __new__() 메소드를 실행해서 객체를 생성하고, __init__()으로 초기화한 후, 최종적으로 호출자에 객체를 반환한다. 파이썬에는 new 연산자가 없으므로 클래스를 호출하는 것은 함수를 호출하는 것과 동일하다(일반적으로 클래스를 호출하면 해당 클래스의 객체가 생성되지만, __new__() 메소드를 오버라이딩하면 다르게 동작할 수도 있다).
  • Class instances
    : 클래스가 __call__() 메소드를 구현하면 이 클래스의 객체는 함수로 호출될 수 있다
  • Generator functions
    : yield 키워드를 사용하는 함수나 메소드. 이 함수가 호출되면 제너레이터 객체를 반환한다
  • Native coroutine functions
    : async def로 정의된 함수나 메소드. 호출될 때, 코루틴(coroutine) 객체가 반환된다. (Python 3.5에서 추가됨)
  • Asynchronous generator functions
    : async def로 정의되면서, 내부에 yield 키워드가 있는 함수나 메소드. 호출될 때, async for를 사용하는 비동기 제너레이터가 반환됨. (Python 3.6에서 추가됨)

제너레이터, native coroutine, asynchronous generator 함수는 어플리케이션 데이터가 아니라 어플리케이션 데이터를 생성하거나 유용한 동작을 수행하기 위해 추가 처리가 필요한 객체라는 점에서 다른 Callable 객체와는 다릅니다. 이들에 대해서는 나중에 다루어 포스팅하도록 하겠습니다.

 

파이썬에서는 다양한 콜러블 타입이 존재하므로, callable() 내장 함수를 통해서 호출할 수 있는 객체인지 판단하는 방법이 가장 안전합니다.

 


User-Defined Callable Types

파이썬 함수가 실제 객체일 뿐만 아니라, 모든 파이썬 객체가 함수처럼 동작하게 만들 수 있습니다. 이는 단지 __call__() 인스턴스 메소드를 구현하기만 하면 됩니다.

 

다음 예제 코드는 BingoCage 클래스를 구현합니다. 이 클래스는 iterable 객체를 받아서 무작위 순으로 항목의 리스트를 내부에 저장합니다. 이 인스턴스를 호출하면 항목을 하나 꺼냅니다.

import random

class BingoCage:
    def __init__(self, items):
        self._items = list(items)
        random.shuffle(self._items)
    
    def pick(self):
        try:
            return self._items.pop()
        except IndexError:
            raise LookupError('pick from empty BingoCage')
    
    # Shortcut to bingo.pick(): bingo()
    def __call__(self):
        return self.pick()

위에서 구현한 BingoCage를 사용하는 예는 다음과 같습니다. bingo 객체를 어떻게 함수처럼 호출할 수 있는지, callable() 내장 함수가 이 객체를 콜러블 객체로 인식하는지 확인합니다.

BingoCage의 경우 객체를 함수처럼 호출할 때마다 항목을 하나씩 꺼낸 후 변경된 상태를 유지해야 하는데, __call__() 메소드를 구현하면 이런 객체를 생성하는 것은 쉽습니다. __call__을 사용하는 다른 예로는 데코레이터(decorator)를 구현하는 것이 있습니다. 데코레이터는 반드시 callable하지만, 이는 때때도 데코레이터의 호출 간에 상태를 기억할 수 있어 편리합니다. 또는 복잡한 연산을 여러 메소드로 분리할 수 있습니다.

내부 상태를 가지는 함수를 만드는 다른 방법은 클로저(closure)가 있습니다.

 


Positional Parameters and Keyword-Only Parameters

파이썬 함수에서 가장 훌륭한 기능 중 하나는 극도로 유연한 파라미터 핸들링 메커니즘입니다. 함수를 호출할 때, iterable과 mapping을 언패킹하는 *와 ** 기호가 이 메커니즘과 밀접하게 연관되어 있습니다. 이 기능이 어떻게 동작되는지 살펴보기 위해서 다음의 예제 코드를 살펴보겠습니다.

def tag(name, *content, class_=None, **attrs):
    """Generate one or more HTML tags"""
    if class_ is not None:
        attrs['class'] = class_
    attr_pairs = (f' {attr}="{value}"' for attr, value
                    in sorted(attrs.items()))
    attr_str = ''.join(attr_pairs)

    if content:
        elements = (f'<{name}{attr_str}>{c}</{name}>' for c in content)
        return '\n'.join(elements)
    else:
        return f'<{name}{attr_str} />'

이렇게 정의된 함수는 다음과 같이 다양한 방식으로 호출할 수 있습니다.

키워드 전용 인수(keyword-only arguments)는 파이썬 3에서 추가된 것입니다. 위 예제에서 class_ 파라미터만 키워드 인수로 전달될 수 있으며, 익명의 위치 인수(position argument)로는 절대로 전달되지 않습니다. 함수를 정의할 때 키워드 전용 인수를 지정하려면 *가 붙은 인수 뒤에 이름을 지정합니다. 가변 개수의 위치 인수를 지원하지 않으면서 키워드 전용 인수를 지원하고 싶으면, 다음과 같이 *만 인수 리스트에 포함시키면 됩니다.

키워드 전용 인수는 기본값을 지정하지 않아도 되며, 위의 예제 코드처럼 b를 필수 인수로 만들 수 있습니다.

 

Positional-only parameters

파이썬 3.8부터 사용자 정의 함수에서 positional only parameters를 지정할 수 있습니다. 이 기능은 divmod(a, b)와 같은 내장 함수에서는 항상 존재했었는데, 오직 positional parameters로만 호출할 수 있었으며, divmod(a=10, b=4)처럼 사용할 수 없습니다.

 

위치 전용 파라미터를 가지는 함수를 정의하기 위해서는 '/'를 파라미터 리스트에 사용합니다. 

What's New In Python 3.8에서는 divmod 내장 함수가 어떻게 이렇게 동작하는지 보여줍니다.

def divmod(a, b, /):
    return (a // b, a % b)

'/'의 왼쪽에 있는 모든 인수들은 위치 전용 파라미터입니다. 그리고 '/' 이후의 인수들은 다른 인수들로 지정할 수 있습니다.

 

예를 들어, 위에서 구현한 tag 함수를 다시 살펴보겠습니다. 만약 name 파라미터를 위치로만 전달받도록 하려면, '/'를 다음과 같이 함수 인수 리스트에 추가해주면 됩니다.

def tag(name, /, *content, class_=None, **attrs):
    ...

 


Packages for Functional Programming

파이썬 창시자 귀도는 파이썬이 함수형 프로그래밍 언어를 지향하지 않았다고 하지만, operator와 functools 같은 패키지들의 지원 덕분에 파이썬에서도 제법 함수형 코딩 스타일을 사용할 수 있습니다. 이 두 패키지에 대해서 알아보도록 하겠습니다.

 

operator Module

함수형 프로그래밍을 할 때 산술 연산자를 함수로 사용하는 것이 편리할 때가 종종 있습니다. 예를 들어 팩토리얼을 계산하기 위해 재귀적으로 함수를 호출하는 대신 숫자 시퀀스를 곱하는 경우를 생각해보겠습니다. 합계를 구할 때는 sum() 이라는 함수가 있지만, 곱셈에 대해서는 이에 해당하는 함수가 없습니다. reduce() 함수를 사용할 수는 있지만, reduce()는 시퀀스의 두 항목을 곱하는 함수를 필요로 합니다. 람다를 이용해서 reduce()를 사용하는 방법은 다음과 같습니다.

from functools import reduce

def fact(n):
    return reduce(lambda a, b: a*b, range(1, n+1))

'lambda a, b: a*b와 같은 람다식을 작성하는 수고를 덜기 위해 operator 모듈은 수십 개의 연산자에 대응하는 함수를 제공합니다. 이 함수를 이용하면 다음과 같이 구현할 수 있습니다.

from functools import reduce
from operator import mul

def fact(n):
    return reduce(mul, range(1, n+1))

 

operator 모듈은 시퀀스에서 항목을 가져오는 람다를 대체하는 itemgetter() 함수와 객체의 속성을 읽는 람다를 대체하는 attrgetter() 함수를 제공합니다.

 

다음 예제 코드는 특정 필드의 값을 기준으로 튜플의 리스트를 정렬할 때 일반적으로 사용하는 itemgetter()를 보여줍니다. 이 예제에서 1번 필드인 국가 코드로 정렬된 도시들을 출력합니다. 본질적으로 itemgetter(1)은 lambda fields: fields[1]과 동일하며, 주어진 컬렉션에 대해 1번 인덱스 항목을 반환하는 함수를 생성합니다.

itemgetter()에 여러 개의 인덱스를 인수로 전달하면, 생성된 함수는 해당 인덱스의 값들로 구성된 튜플을 반환합니다.

itemgetter()는 [] 연산자를 사용하므로 시퀀스뿐만 아니라 매핑 및 __getitem__()을 구현한 모든 클래스를 지원합니다.

 

itemgetter()의 형제인 attrgetter()는 이름으로 객체 속성을 추출하는 함수를 생성합니다. attrgetter()에 여러 속성명을 인수로 전달하면, 역시 해당 속성값으로 구성된 튜플을 반환합니다. 게다가 속성명에 점(.)이 포함되어 있으면 attrgetter()는 내포된 객체를 찾아서 해당 속성을 가져옵니다. 아래 코드는 이러한 내용을 잘 보여줍니다.

 

operator에 정의된 함수들 중 일부는 다음과 같습니다. 언더바로 시작하는 이름은 주로 구현에 관련된 함수이므로 여기서는 생략했습니다.

54개의 함수 대부분은 이름으로 쉽게 그 동작을 추측할 수 있습니다. iadd와 iand처럼 i로 시작하는 함수명은 += 및 &=와 같은 복합 할당 연산자입니다. 이 함수들은 첫 번째 인수가 가변형인 경우에는 첫 번째 인수를 변경하고, 불변형인 경우에는 i가 없는 함수와 동일하게 연산 결과를 반환합니다.

 

나머지 operator 함수들 중 마지막으로 methodcaller()에 대해서 살펴보겠습니다. methodcaller()는 실행 중 함수를 생성한다는 점에서 attrgetter()나 itemgetter() 메소드와 비슷합니다. 다음 예제 코드에서 보는 것처럼 methodcaller()가 생성한 함수는 인수로 전달받은 객체의 메소드를 호출합니다.

위의 예제 코드에서 upcase()는 단지 methodcaller()의 사용법은 보여주기 위한 것입니다.

 

Freezing Arguments with functools.partial

functools 모듈은 몇 가지 고차 함수를 통합합니다. 그중 가장 널리 알려진 함수가 reduce() 함수입니다. 다른 함수로는 partial이 있습니다. 어떤 함수가 있을 때 partial()을 적용하면 미리 결정된 값으로 바운딩된 원본 콜러블의 인수를 갖는 새로운 콜러블을 생성합니다. 이는 하나 이상의 인수를 받는 함수가 그보다 적은 인수를 받는 콜백 함수를 필요로 하는 API에 사용하고자 할 때 유용합니다. 아래 코드에서 간단한 예제를 보여줍니다.

 

유니코드를 정규화하기 위한 unicode.normalize() 함수는 partial()을 더욱 유용하게 사용할 수 있습니다. 다양한 언어로 구성된 텍스트를 사용할 때는 텍스트를 비교하거나 저장하기 전에 unicode.normalize('NFC', s)를 문자열 s에 적용하면 좋습니다. 이런 작업을 자주 수행한다면, 다음과 같이 nfc() 함수를 정의해두면 편리하게 사용할 수 있습니다.

partial()은 첫 번째 인수로 콜러블을 받으며, 그 뒤에 원하는 만큼의 바인딩할 위치 인수와 키워드 인수가 나옵니다.

 

다음 예제 코드는 위에서 구현한 tag() 함수에 partial()을 적용해서 위치 인수 하나와 키워드 인수 하나를 고정시킵니다.

파이썬 3.4에서 소개된 functools.partialmethod() 함수는 partial()과 동일하지만 메소드에 대해 동작하도록 설계되어 있습니다.

 

functools 모듈은 함수 데코레이터로 사용되도록 디자인된 고차 함수를 가지고 있습니다(ex, cache, singledispatch 등). 이러한 함수들은 이후에 데코레이터에 대한 포스팅에서 다루어 보도록 하겠습니다.. !

'프로그래밍 > Python' 카테고리의 다른 글

[Python] A Pythonic Object  (0) 2022.03.19
[Python] 데코레이터와 클로저  (0) 2022.03.18
[Python] 객체 참조, 가변성, 재활용  (0) 2022.03.16
[Python] 텍스트와 바이트  (0) 2022.03.15
[Python] 딕셔너리와 집합  (0) 2022.03.13

댓글