위대한 Python의 Function 알아보기

이 글의 원제는 Python’s Functions Are First-Class입니다. 최대한 읽기 쉽게 풀어쓰려 노력했으며, 이 글의 대상 독자는 Python 입문자 수준입니다. 또한 이 글에서 어필하는 것과는 다르게 함수가 first-class object인 언어는 예상외로 매우 많습니다. (요즘 시대에 C와 같은 언어를 제외하고는 대부분 first class function을 갖고 있습니다.) Python만의 강점이라고 보기는 어려우니 오해의 소지가 없었으면 합니다.

위대한 Python의 Function 알아보기

Python의 함수는 최고의 객체입니다. 함수를 변수에 지정할 수도 있고, 자료구조에 넣을 수도 있으며, 다른 함수의 인자로 넘길 수도 있고, 함수를 값으로 다른 함수에 넘길 수도 있습니다.

Python 함수의 컨셉을 제대로 이해하는 것은 lambda 구문이나 decorator pattern과 같은 심화된 기능을 더 직관적으로 이해할 수 있게 해줍니다.

이 가이드에서는 이런 직관적인 이해를 돕기 위한 몇가지 예를 제공합니다. 이 예들을 직접 Python 인터프리터에서 실행해볼 수도 있으나 모든 예는 순서대로 제공되기 때문(이어짐)에 앞에서부터 따라해보시는 것이 좋습니다.

약간의 생각이 필요한 함수의 컨셉부터 이해하도록 해봅시다. 아, 걱정할 필요는 없어요 그냥 단순한 것들이니까요. 저는 이미 갔다온 곳입니다. 글을 읽는 당신은 벽에 머리를 부딛힌 것처럼 느껴지겠지만 갑자기 이해가 되는 순긴이 오면서 당신도 제가 갔다온 그 곳에 다다를 수 있을 겁니다.

이 튜토리얼에서 저는 다음과 같은 yell(소리지르다) 함수를 예로 사용할 것입니다. 쉽게 알아볼 수 있는 좋은 예니까요:

def yell(text):
    return text.upper() + '!'

>>> yell('hello')
'HELLO!'

함수 = 객체

Python 프로그램의 모든 데이터는 객체 또는 객체들끼리의 관계로 설명됩니다. strings, lists, modules, functions 같은 것들이 모두 객체죠. Python에서 함수라고 딱히 특별한 건 없습니다.

yell 함수가 객체기 때문에 yell을 다른 변수에 할당할 수도 있습니다, 다른 객체들처럼 말이죠:

>>> bark = yell

위에 쓴 예는 함수를 호출하지 않고 yell과 연결되어 있는 함수 객체를 가져와 그 객체를 가리키는 새로운 변수인 bark를 만듭니다. 이를 통해 같은 함수를 bark라고 불리는 객체를 통해 호출할 수 있게 됩니다:

>>> bark('woof')
'WOOF!'

함수 객체와 변수명은 아예 다르게 구분됩니다. 더 자세히 설명해드리죠: 우리는 같은 함수를 가리키는 다른 이름인 bark라는 객체를 갖고 있기 때문에 함수를 선언할 때 지었던 원래 이름인 yell을 지워도 아무 문제가 없게 됩니다:

>>> del yell

>>> yell('hello?')
NameError: name 'yell' is not defined

>>> bark('hey')
'HEY!'

그런데 Python은 모든 함수가 생성되는 시점에 디버깅을 좀 더 편하게 하기 위해 구분자(identifier)를 붙입니다. 이 내부적인 구분자를 __name__ 속성으로 접근할 수 있습니다:

>>> bark.__name__
'yell'

함수의 이름은 여전히 yell이지만 코드에서 함수에 접근하는 방법에는 영향을 끼치지 않습니다. 이 구분자는 그저 디버깅을 지원하기 위한 목적으로 생성되기 때문입니다. 이번 예에서는 함수를 기리키는 변수함수 그 자체는 아예 다른 것임을 알면 됩니다.

(Python 3.3부터 __qualname__이라는 속성이 추가되었는데 이 속성은 순수 함수명과 클래스 이름을 구분짓기 위해 한 번 걸러진 값을 제공합니다. 예로 A 클래스의 b 함수의 __name__b지만 __qualname__A.b입니다.)

자료구조 속 함수

Python에서의 함수는 객체기 때문에 다른 객체들과 동일하게 자료구조 속에 넣어서 사용할 수도 있습니다. 예로, 리스트에 함수를 넣을 수도 있습니다:

>>> funcs = [bark, str.lower, str.capitalize]
>>> funcs
[<function yell at 0x10ff96510>,
 <method 'lower' of 'str' objects>,
 <method 'capitalize' of 'str' objects>]

리스트 내에 있는 함수에 접근하는 방법은 다른 객체와 동일합니다:

>>> for f in funcs:
...     print(f, f('hey there'))
'HEY THERE!'
'hey there'
'Hey there'

게다가 객체를 꺼내서 변수에 넣지 않고도 리스트 안에 있는 함수를 호출할 수도 있습니다. 다음 단 한 줄로 말이죠:

>>> funcs[0]('heyho')
'HEYHO!'

다른 함수로 넘기는 함수

계속 이야기하고 있지만 함수는 순수 객체기 때문에 다른 함수의 인자로 넘길 수 있습니다. 예로 다음 greet 함수는 인자로 넘겨받은 func를 실행시켜 문자열을 변환한 후 출력합니다:

def greet(func):
    greeting = func('Hi, I am a Python program')
    print(greeting)

이렇게 하면 다른 함수를 넘겨가며 결과를 바꿀 수 있게 됩니다. yell 함수를 greet 함수에 넘기면 어떻게 되나 보죠:

>>> greet(yell)
'HI, I AM A PYTHON PROGRAM!'

조금 다르게 출력해보기 위해 새로운 함수를 만들어서 넘겨봅시다. Python 앱이 옵티머스 프라임처럼 소리지르기를 원하지 않는다면 다음 whisper 함수가 더 나을겁니다.

def whisper(text):
    return text.lower() + '...'

>>> greet(whisper)
'hi, i am a python program...'

다른 함수의 인자로 함수를 넘길 수 있는 점은 무척 강력한 부분입니다. 함수를 추상화한 후 원하는 행동(behaviour)을 넘겨 작동시킬 수 있죠. 이 예에서는 한 개의 greet 함수를 만들어놓고 다른 함수를 넘겨가며 결과값을 바꾸었습니다.

다른 함수를 함수의 인자로 받을 수 있는 함수를 고계함수(higher-order function)라고 부릅니다. 함수형 프로그래밍(functional programming)의 중요한 부분이기도 하죠.

Python에서 일반적으로 고계함수의 예로 쓰이는 함수는 바로 map 함수입니다. map 함수는 함수와 순환 가능한 객체(iterable)를 받아 객체에 있는 각 항목을 함수 인자로 넘겨 결과를 받을 수 있게 해줍니다. yell함수를 map의 인자로 넘겨 전부 매핑시켜 봅시다:

>>> list(map(yell, ['hello', 'hey', 'hi']))
['HELLO!', 'HEY!', 'HI!']

map 함수가 객체 내 모든 항목을 돌면서 각 객체마다 yell 함수를 실행시켰습니다.

함수 내에 함수 두기

Python은 함수 내에 다른 함수가 정의되는 것을 허용합니다. 이런 함수들은 중첩함수(nested functions) 또는 내부함수(inner functions)라고 불립니다.

def speak(text):
    def whisper(t):
        return t.lower() + '...'
    return whisper(text)

>>> speak('Hello, World')
'hello, world...'

자 봅시다, speak 함수를 실행할 때마다 whisper라는 함수를 만들고 그 함수를 호출합니다.

이점을 확인해봅시다 -- whisperspeak 밖에서는 존재하지 않습니다:

>>> whisper('Yo')
NameError:
"name 'whisper' is not defined"

>>> speak.whisper
AttributeError:
"'function' object has no attribute 'whisper'"

그런데 whisper 함수를 speak 밖에서 접근하고 싶을 때는 어떻게 하면 될까요? 함수는 객체입니다 -- 상위함수에서 내부함수를 반환(return)하기만 하면 됩니다.

예를 들어봅시다. 2개의 내부함수를 정의하는 함수가 있습니다. 상위함수에 넘겨진 인자에 따라 각각 다른 내부함수를 반환합니다:

def get_speak_func(volume):
    def whisper(text):
        return text.lower() + '...'
    def yell(text):
        return text.upper() + '!'
    if volume > 0.5:
        return yell
    else:
        return whisper

어떻게 get_speak_func 함수가 내부함수를 호출하지 않는지 알아봅시다 -- 이 함수는 volume 인자에 따라 적절한 함수를 고르고 함수 객체로 반환합니다:

>>> get_speak_func(0.3)
<function get_speak_func.<locals>.whisper at 0x10ae18>

>>> get_speak_func(0.7)
<function get_speak_func.<locals>.yell at 0x1008c8>

물론 저렇게 받은 함수를 호출할 수도 있습니다, 직접 호출하던가 아니면 변수에 할당한 후에 호출하던가 하는 방법으로요:

>>> speak_func = get_speak_func(0.7)
>>> speak_func('Hello')
'HELLO!'

잠깐 생각좀 해봅시다. 이런 특성은 인자로 함수가 어떻게 행동할지를 받을 수 있을 뿐만 아니라 특정 행동을 반환할 수도 있다는 것을 의미합니다. 얼마나 멋진가요?

음, 이제 슬슬 어려워질 때가 됐습니다. 커피 한 잔 마시고 와서 계속 이어갑시다.

클로저

어떻게 함수가 내부함수를 포함할 수 있고 이 내부함수를 반환할 수 있다는 사실도 확인했습니다.

이제 조금씩 어려워질테니 준비좀 하고 갑시다 -- 함수형 프로그래밍의 영역으로 들어서게 됩니다. (커피 마시고 왔죠?)

함수는 다른 함수를 반환할 수 있을 뿐만 아니라 이렇게 반환된 내부 함수는 상위함수의 상태를 끌고올 수 있습니다.

설명을 쉽게 하기 위해 위에서 작성했던 get_speak_func 함수를 다시 작성해보겠습니다. 새로운 get_speak_func 함수는 volumetext 인자를 즉시 사용해서 반환된 함수가 바로 실행될 수 있게끔 합니다:

def get_speak_func(text, volume):
    def whisper():
        return text.lower() + '...'
    def yell():
        return text.upper() + '!'
    if volume > 0.5:
        return yell
    else:
        return whisper

>>> get_speak_func('Hello, World', 0.7)()
'HELLO, WORLD!'

whisper, yell의 이름을 가진 내부함수를 봅시다. 저 함수들은 text 인자를 갖고있지 않지만 상위함수에서 받은 text 변수에 여전히 접근할 수 있습니다. 실제로 저 함수들은 인자로 받은 값들을 캡쳐하고 "기억"하고 있는 셈입니다.

이런 행동을 하는 함수를 클로저(lexical closures, 짧게는 closures)라고 부릅니다. 클로저는 프로그램의 흐름이 해당 스코프를 벗어나도 유효범위(lexical scope)에 있는 값을 기억합니다.

실용적인 측면에서 이것은 함수가 특정 행동을 반환할 수 있을 뿐만 아니라 행동을 반환할 때 미리 설정을 해서 반환할 수도 있다는 것을 의미합니다. 이를 분명히 보여줄 다음 예가 있습니다:

def make_adder(n):
    def add(x):
        return x + n
    return add

>>> plus_3 = make_adder(3)
>>> plus_5 = make_adder(5)

>>> plus_3(4)
7
>>> plus_5(4)
9

이 예에서 make_adder 함수는 adder 함수를 만들고 설정하기 위한 팩토리(factory)로 제공됩니다. 어떻게 adder 함수가 make_adder 함수의 인자로 넘겨진 n에 접근하는지(유효범위) 확인해보세요.

함수처럼 행동하는 객체

모든 함수는 객체지만 모든 객체는 함수가 아닙니다. 하지만 함수가 아닌 객체들을 호출할 수 있게(callable)끔 만들 수는 있습니다.

만약 어떤 객체가 호출할 수 있는 객체(callable)면 그것은 그 객체를 ()로 둘러싸고 함수 인자를 넘길 수 있다는 것을 의미합니다. 여기 호출 가능한 객체(callable)의 예가 있습니다:

class Adder:
    def __init__(self, n):
         self.n = n
    def __call__(self, x):
        return self.n + x

>>> plus_3 = Adder(3)
>>> plus_3(4)
7

실제로 보면 객체 인스턴스를 함수와 같은 방법으로 호출하는 것은 객체의 __call__ 메소드를 실행시키는 것과 같습니다.

물론 모든 객체를 호출 가능한 객체로 만들 수는 없습니다. 객체가 호출 가능한지 아닌지 확인하기 위해 callable이라는 함수가 Python 내부에 내장된 이유이기도 하죠:

>>> callable(plus_3)
True
>>> callable(yell)
True
>>> callable(False)
False

정리

  • Python에서 함수를 포함한 모든 것은 객체입니다. 이 모든 객체는 변수에 할당할 수 있고, 자료구조에 넣을 수도 있으며, 다른 함수의 인자로 넘길 수도 있고, 값으로 다른 함수에 넘길 수도 있습니다. (first-class functions 이라 부릅니다.)
  • 함수 또한 객체이기 때문에 함수를 추상화한 후 원하는 행동(behaviour)을 넘겨 작동시킬 수 있습니다.
  • 함수 내에는 다른 함수가 존재할 수 있고 이런 내부함수는 상위함수의 값을 가질 수 있습니다. 이런 함수를 클로저라고 부릅니다.
  • 모든 함수는 객체지만 모든 객체는 함수가 아닙니다. 하지만 함수가 아닌 객체들을 호출할 수 있게(callable)끔 만들 수는 있습니다.