먹고 기도하고 코딩하라

[인프런 리프 2기] 3주 - 파이썬 일급 함수 본문

일상

[인프런 리프 2기] 3주 - 파이썬 일급 함수

2G Dev 2021. 3. 28. 21:21
728x90
728x90

이전 글 보기

 

[인프런 리프 2기] 3주 - 파이썬 시퀀스

오늘은 인프런 리프 3주차 미션인 섹션 4를 정리해 보겠습니다. 제가 수강하는 강의는 [우리를 위한 프로그래밍], 파이썬 중급 과정 인프런 오리지널 강의입니다. 섹션 4 : 파이썬 시퀀스 파이썬

dev-dain.tistory.com

 

 

오늘은 인프런 리프 3주차 미션인 섹션 5를 정리해 보겠습니다.

 

제가 수강하는 강의는 [우리를 위한 프로그래밍], 파이썬 중급 과정 인프런 오리지널 강의입니다.

 

 

 

 

섹션 5 : 파이썬 일급 함수

시퀀스까지는 해볼만했으나 일급 함수 부분부터는 본격적으로 어려워지는 것 같네요. 내용이 많지는 않지만 이해하기 까다로운 내용이 많습니다.

섹션 5에서는 다음과 같은 내용을 다룹니다:

(1) 파이썬의 일급 객체, 함수
(2) 함수 인수 고정 방법 (partial)
(3) 파이썬 변수 범위 (scope)
(4) 클로저 기초 소개
(5) 데코레이터

 

 

 

5-1. 파이썬의 일급 객체, 함수

일급 함수는 함수형 프로그래밍과 관련이 있는데요, 함수형 프로그래밍을 가능하게 하는 것이 바로 일급함수이기 때문입니다. 파이썬 함수에는 런타임 초기화/변수 할당 가능/함수 인수 전달 가능/함수 결과 반환 가능(return) 등의 특징이 있는데요. 파이썬 프로그래밍에서 함수는 객체로 취급이 됩니다.

재귀 방식을 쓰는 팩토리얼 함수를 선언해 type을 알아보고, dir도 찍어 보겠습니다.

 

def factorial(n):
  '''Factorial function -> n : int'''
  if n == 1:
    return 1
  return n * factorial(n - 1)
  
print(factorial(5))	# 120
print(type(factorial))	# <class 'function'>
print(dir(factorial)) # 함수인데 __repr__, __lt__ 등이 있음. 객체 취급당함
# ['__annotations__', '__call__', '__class__', '__closure__', '__code__', '__defaults__', '__delattr__', '__dict__', '__dir__', '__doc__', '__eq__', '__format__', '__ge__', '__get__', '__getattribute__', '__globals__', '__gt__', '__hash__', '__init__', '__init_subclass__', '__kwdefaults__', '__le__', '__lt__', '__module__', '__name__', '__ne__', '__new__', '__qualname__', '__reduce__', '__reduce_ex__', '__repr__', '__setattr__', '__sizeof__', '__str__', '__subclasshook__']

print(factorial.__name__)	# factorial
print(factorial.__code__) # 함수 메모리 위치와 드라이브 위치, 줄번호까지 출력함
# <code object factorial at 0x000002EA691B63A0, file "d:\studyspace\leaf-python\ch5_1.py", line 
12>

factorial dir을 보면, 함수인데도 클래스에서 쓸 수 있는 __repr__이나 __lt__ 등이 있는 것을 볼 수 있습니다. 파이썬의 함수는 객체 취급을 받는다는 것을 확인할 수 있는 대목입니다.

또한 __call__은 함수가 callable, 즉 호출 가능하다는 뜻인데요. 내부적으로 __call__이 존재하기 때문에 매개변수를 넣어 factorial(5) 이런 식으로 호출이 가능하다는 것을 의미하는 것입니다.

__code__의 경우도 눈여겨 볼 만한데, 해당 함수가 위치한 메모리의 위치와 드라이브 위치, 줄 번호까지 출력해줍니다.

 

이렇게 만든 함수는 변수에 할당할 수도 있습니다.

 

var_func = factorial  # 변수에 함수 자체를 할당
print(var_func)	# <function factorial at 0x000002EA691A4EE0>
print(factorial)	# <function factorial at 0x000002EA691A4EE0>
print(var_func(10))	# 3628800

var_func라는 변수에 factorial을 호출해서 return하는 값을 넣은 게 아니라 그냥 factorial 자체를 할당해 줬습니다. 그렇게 해서 var_func와 factorial을 출력해 보면, 같은 주소값에 있는 것을 확인할 수 있습니다. 즉, var_func라는 변수 역시 factorial이 있는 주소를 참조하는 레퍼런스라고 볼 수 있죠. factorial 함수와 완전 동일하게 사용하는 것 역시 가능합니다.

 

람다 함수와 functools 라이브러리의 reduce 모듈은 강의에서 자세히 설명하고 있으니 강의를 참고하시기 바랍니다. ^^

 

 

 

5-2. 함수 인수 고정 방법 (partial)

여러 인수를 받는 함수, 혹은 하나의 인수를 받지만 그 인수를 항상 하나의 값으로 고정하고 싶은 함수를 만들고 싶다면 어떻게 해야 할까요? 답은 functools 라이브러리의 partial 모듈을 쓰는 것입니다. 어떤 값에 무조건 3을 곱하는 함수를 만들기 위해, operator 라이브러리의 mul 모듈까지 import해서 쓰겠습니다.

 

from operator import mul
from functools import partial

three = partial(mul, 3)  # 3 * ? 가 됨
print(three(7))  # 3 * 7이 됨. 21

그냥 곱하면 되지 mul을 굳이 import하는 이유는 partial의 첫 번째 인자로 함수가 들어가야 하기 때문입니다. mul 모듈은 '*' 연산자, 곱셈 연산자와 완전히 똑같은 기능을 합니다. 실제로 mul(3, 5)를 하면 결과로 15가 나오는 것을 확인할 수 있습니다.

partial(func, value)는 func를 하되 바꿀 수 없는 불변 고정 인자로 value를 갖는 함수를 반환합니다. 그래서 three는 첫 번째 고정 인자가 3인 곱셈 함수가 됩니다. 즉, three(7)은 3 * 7, three(10)은 3 * 10이 되는 겁니다.

이 고정 인자는 더 추가할 수도 있습니다.

 

six = partial(three, 6)
print(six())	# 18

기존의 three 함수의 나머지 남은 인자 자리에 6을 할당했습니다. 이제 six는 18이 됩니다.

그렇다면 이미 할당된 인자를 걷어내고 다른 인자를 넣는다면 무슨 일이 일어날까요?

 

print(six(3))	# TypeError: mul expected 2 arguments, got 3

TypeError가 발생합니다. mul 함수는 2개의 인자만 받아야 하는데 인자가 3개 들어왔다는 뜻이죠.

 

 

 

5-3. 파이썬 변수 범위 (scope)

클로저를 배우기 전 변수의 스코프를 먼저 알아야겠습니다.

스코프란, 어떤 변수나 함수를 접근할 수 있는 범위를 뜻합니다. 함수 내부와 함수 바깥 전역 스코프에 같은 이름의 변수가 있다면 로컬 스코프 변수가 글로벌 스코프의 변수를 가려 로컬 스코프가 우선인 것은 다들 아실 듯합니다.

그런데 다음과 같은 경우는 어떨까요?

 

c = 30
def func_v(a):
  print(a)
  print(c)
  c = 40
  
func_v(10)

결과는 UnboundLocalError: local variable 'c' referenced before assignment로, 에러가 납니다.

왜 그럴까요? 변수 c가 전역 스코프에 있으니 func_v에서는 전역 스코프의 c를 쓸 것 같다는 생각이 들지 않으세요? (저는 그랬습니다 😅)

하지만 함수 내에 같은 이름의 변수가 선언되어 있다면, 로컬 스코프에 있는 변수를 쓰려고 합니다. 이 경우에는 c = 40이라는 c 변수 선언문이 print(c) 다음에 등장했으므로 값 할당 전에 사용됐기 때문에 UnboundLocalError가 뜹니다.

그럼 전역 변수인 c를 쓰고 싶다면 어떻게 해야 할까요? 간단하게 키워드 하나만 붙여 주면 됩니다.

 

c = 30
def func_v(a):
  global c  # 전역 스코프의 c 추가
  print(a)	# 10
  print(c)	# 30
  c = 40
  
func_v(10)
print(c)	# 40

사용하려는 전역 변수 이름 앞에 global 키워드를 붙여주면, 비록 지금 함수 안에 있으나 전역 변수의 c를 쓰겠다는 말이 됩니다. func_v 안의 c 역시 로컬 변수 c가 아니라 글로벌 변수 c가 됩니다. 그래서 func_v 호출 이후의 c 값이 40으로 변한 것을 볼 수 있습니다.

 

 

 

 

5-4. 클로저 기초 소개

자바스크립트를 할 때 절 참 많이 애쓰게 한 개념이 바로 클로저였는데요. 파이썬에도 역시 클로저 개념이 있습니다. 파이썬에서 클로저를 사용하는 이유는 주로 서버 프로그래밍에서의 동시성(Concurrency) 제어라고 합니다. 운영체제 수업 시간에 같은 자원에 한꺼번에 여러 프로세스가 접근하려고 하면 교착 상태(deadlock)가 생겨 아무도 자원을 쓸 수 없다는 설명을 들은 적이 있는데요. 파이썬에서도 이런 프로그래밍을 하려면 교착 상태가 일어날 수 있기 때문에 메모리 자체를 공유하지 않고, 대신 메시지 전달로 처리하고자 클로저를 사용하게 되는 것입니다.

 

클로저는 불변 상태를 기억합니다. 공유되지만 변경되지 않는 immutable한 특징을 갖고 있습니다. 클로저는 외부에서 호출된 함수 변수값과 상태를 복사 후 저장하고, 나중에 접근할 수 있도록 합니다.

함수가 끝나도 값을 기억하고 있다는 게 특히 중요한 특징입니다. 프로세스 작업에서 타임아웃 인터럽트나 I/O 인터럽트로 작업이 중간에 중단되고 다음에 다시 돌아올 자기 차례를 기다린 다음 다시 작업을 시작할 때, 자기가 작업하던 값이 아니라 초기화되어 있거나 값이 바뀌어 있으면 곤란하겠죠?

클로저를 알아야 포스팅 마지막의 데코레이터와 다음 주에 다룰 coroutine 등을 알 수 있기 때문에 클로저의 이해가 중요하다고 하네요.

 

클래스를 하나 만들어 클로저처럼 사용해 봅시다.

 

class Averager():
  def __init__(self):
    self._series = []
  
  def __call__(self, v): # 클래스를 함수처럼 사용할 수 있음
    self._series.append(v)
    print('inner >> {} / {}'.format(self._series, len(self._series)))
    return (sum(self._series) / len(self._series))

함수의 평균값을 구하는 클래스인 Averager 클래스를 만들었습니다. __call__ 매직 메소드를 구현해 클래스 객체를 함수처럼 사용할 수 있게 만들었는데요.

여기서 self._series의 리스트가 바로 상태를 기억하는 공간입니다. self로 되어 있기 때문에 클래스 변수가 아니죠. 이걸 프로세스라고 생각해 보면 프로세스마다 겹치지 않는 자기만의 _series를 갖고, 또 클래스 객체가 소멸되기 전까지는 _series 안의 값을 계속 가지고 있기 때문에 클로저의 취지와 잘 맞습니다.

이제 클래스 인스턴스를 하나 만들어 누적되는지 실험해 보겠습니다.

 

averager_cls = Averager()

print(averager_cls(10)) # 클래스를 함수처럼 실행하는 중
# inner >> [10] / 1
# 10.0

print(averager_cls(30))
# inner >> [10, 30] / 2
# 20.0

print(averager_cls(50))
# inner >> [10, 30, 50] / 3
# 30.0

averager_cls 라는 클래스 인스턴스를 만들어 마치 함수처럼 실행하고 있습니다. __call__ 메소드를 구현했기 때문에 가능한 일입니다. __call__ 내부에서는 _series 내부의 요소와 개수를 출력하고 마지막에는 평균값을 return하는 문장을 실행하고 있는데요. 이걸 그냥 함수로 구현한다면 어떻게 될까요?

 

series = []
def average_func(v):
  global series
  series.append(v)
  return series
  
print(average_func(10))	# [10]
print(average_func(30))	# [10, 30]
print(average_func(50))	# [10, 30, 50]

전역 변수에 series라는 리스트를 하나 만들어 그것을 이용하는 방법을 써야 합니다.

그런데 이 방법은 엄밀히 말하면 클로저는 아닙니다. 그냥 단순히 전역 변수를 이용하는 거니까요.

 

그럼 이제 진짜 클로저를 한 번 사용해 보겠습니다. 보통 클로저는 함수 안에 함수가 들어있고, 외부 함수에서 내부 함수 자체를 return하는 식으로 사용됩니다.

 

def closure_ex1():
  # Free variable (사용하려는 함수 바깥에 선언된 변수)
  series = []
  # 클로저
  def averager(v):
    series.append(v)
    print('inner >>> {} / {}'.format(series, len(series)))
    return sum(series) / len(series)
    
  return averager

외부 함수 안에 있지만 내부 함수 바깥 영역에 있는 변수들free variables라고 표현합니다. 이 series에서 상태를 기억할 수 있는데요. 살짝 이해가 안 되죠? ㅎㅎ 어떻게 쓰는지도 모르겠고요. 백문이 불여일타라고 쓰면서 알아봅시다.

 

avg_closure1 = closure_ex1()
print(avg_closure1(3))
# inner >>> [3] / 1
# 3.0

print(avg_closure1(30))
# inner >>> [3, 30] / 2
# 16.5

print(avg_closure1(20))
# inner >>> [3, 30, 20] / 3
# 17.666666666666668

print(avg_closure1(40))
# inner >>> [3, 30, 20, 40] / 4
# 23.25

일단 closure_ex1()을 실행해 avg_closure1에 내부 함수인 averager를 받아 옵니다. 이제 avg_closure1은 averager을 쓰듯 쓸 수 있습니다. 당연히 동작도 averager와 똑같이 하게 되고요. 이제 averager를 쓰는 것처럼 v 매개변수를 넣어 호출을 하는데요. 호출하는 값이 그대로 closure_ex1 안의 series에 저장이 되는 것을 볼 수 있습니다.

여기까진 뭐가 특별한가 싶지만 closure_ex1 함수를 한 번 더 실행해서 다른 걸 만들어 보면 이해가 더 빠릅니다.

 

avg_closure2 = closure_ex1()
print(avg_closure2(10))
# inner >>> [10] / 1
# 10.0

print(avg_closure2(15))
# inner >>> [10, 15] / 2
# 12.5

print(avg_closure2(23))
# inner >>> [10, 15, 23] / 3
# 16.0

print(avg_closure2(24))
# inner >>> [10, 15, 23, 24] / 4
# 18.0

print(avg_closure1(0))
# inner >>> [3, 30, 20, 40, 0] / 5
# 18.6

avg_closure1의 series와 avg_closure2의 series가 다른 것을 확인할 수 있습니다. 신기하죠? ㅎㅎ

이렇게 함으로써 이전 상태값을 기억하고 나중에 필요할 때 다시 사용할 수 있는 것입니다.

 

자 여기서 끝이면 뭔가 아쉽죠~ 클로저 함수에는 __code__라는 게 있고, 그 안에 co_ 접두사를 가진 것들이 여러 개가 있습니다. 그 중에서 co_freevars 라는 것을 출력해 보면 free variable인 series가 출력됩니다. 정말 그런지 확인해보자구요.

 

print(dir(avg_closure1.__code__)) # co_가 붙은 것들이 있음
# ['__class__', '__delattr__', '__dir__', '__doc__', '__eq__', '__format__', '__ge__', '__getattribute__', '__gt__', '__hash__', '__init__', '__init_subclass__', '__le__', '__lt__', '__ne__', '__new__', '__reduce__', '__reduce_ex__', '__repr__', '__setattr__', '__sizeof__', '__str__', '__subclasshook__', 'co_argcount', 'co_cellvars', 'co_code', 'co_consts', 'co_filename', 'co_firstlineno', 'co_flags', 'co_freevars', 'co_kwonlyargcount', 'co_lnotab', 'co_name', 'co_names', 'co_nlocals', 'co_posonlyargcount', 'co_stacksize', 'co_varnames', 'replace']

print(avg_closure1.__code__.co_freevars)  # 자유 변수 클로저 영역 series가 나옴
# ('series',)

그리고 __closure__라는 것도 있는데, 여기서 어떻게 잘 들어가 보면 자유 변수(free variable)의 값도 출력할 수 있습니다. 어떻게 할까요?
강의에서 확인해 봅시다. ㅎㅎㅎ

 

 

 

5-5. 데코레이터

클로저도 이미 충분히 어렵고 이해하는 데 진이 쏙 빠지는데, 산 넘어 산이라고 데코레이터라는 정말 이해하기 어려운 개념이 있습니다. 디자인 패턴 공부를 하다 보면 '데코레이터(장식자) 패턴'이라는 게 있는데, 어떤 메소드 기능을 변화시키지 않고 뭔가 부가적인 일을 함수 전후로 추가(확장)하려고 데코레이터 패턴을 쓰고는 합니다. 시간 기록하는 로깅에 사용되기도 하고요. 

파이썬의 데코레이터도 이와 비슷한 역할을 수행합니다. 함수를 수정하지 않은 상태에서 추가 기능을 구현하고 싶을 때 사용하는데요. 주로 어떤 함수 호출 전이나 후에 뭔가 기능을 하기 위해서 사용합니다. 여러 함수에서 똑같은 전/후 기능이 필요할 때 데코레이터를 사용하면 유용합니다.

보통 데코레이터로 쓰는 함수는 다음과 같이 생겼습니다. 클로저 내부의 코드는 다르지만, 데코레이터를 이해하기 위해 여러 아티클을 찾아봤는데 내부 함수에 args가 있냐 없냐는 좀 다르지만 외부 함수의 인자로는 함수를 받더라구요.

 

def decor(func):
  # 클로저 (outer func에서 넘어온 인자를 갖고 있음)
  def closure(*args):
    result = func(*args)
    arg_str = ', '.join(repr(arg) for arg in args)
    print('{} : {} -> {}'.format(func.__name__, arg_str, result))
    return result
    
  return closure

이제 이 데코레이터를 사용할 함수 위에 데코레이터 표시를 해 주면 사용할 수 있습니다.

이 때, sum_func의 numbers와 closure의 args를 유심히 보세요. closure를 반환받아서 인자를 줘서 실행하면, 실제로는 내부적으로 sum_func가 받은 인자로 실행이 됩니다.

 

@decor # 데코레이터
def sum_func(*numbers):
  return sum(numbers)
  
deco = decor(sum_func)
print(type(deco))	# <class 'function'>

@'데코레이터로 쓸 함수 이름'을 써 준 다음 그 줄 아래 함수 선언을 해 줍니다. 그리고 데코레이터(데코레이터를 쓸 함수) 식으로 호출해 준 다음 변수에 할당을 해 줍니다. 이제 deco에는 decor 내부의 closure가 할당이 되어 있는 상태입니다. 

인자를 줘서 호출해 보겠습니다.

 

deco(100, 200, 300, 400, 500)	
# sum_func : 100, 200, 300, 400, 500 -> 1500
# closure : 100, 200, 300, 400, 500 -> 1500

어떤가요? deco에는 decor 안의 closure가 할당되어 있지만, 내부적으로 result = func(*args)를 실행하기 때문에 sum_func(100, 200, 300, 400, 500)가 실행되는 꼴입니다. 그러므로 result는 1500이 나오죠.

좀 더 복잡한 데코레이터 예제는 강의에서 소개하고 있으니 강의를 참고하시기 바랍니다. 

 


 

이번 주 정말 고비였네요. 멘토링 프로그램에 스터디, 리프 활동까지! 바빴던 한 주지만 보람 있습니다. 어려운 내용으로 뇌를 깨우는 기분이라 상쾌하고요. ㅎㅎ

파이썬에서도 클로저 개념에서 여러 가지 다른 스킬들이 뻗어나오는구나 느끼고 이런 기본 개념부터 탄탄하게 다져놓는 게 중요하겠다는 생각이 다시 한 번 드는 강의였습니다. 솔직히 한 번 들어서는 이해가 잘 안 되고 왠지 어렵지만 꾸준히 복습하고 예제로 많이 연습하다 보면 어느새 술술 그 원리를 꿸 수 있게 되지 않을까 싶습니다.

 

나머지 자세한 내용은 강의에서 확인해 보시기 바랍니다. ㅎㅎ

 

 

이 포스팅은 '인프런 리프' 활동의 일환으로, <우리를 위한 프로그래밍 : 파이썬 중급> 강의를 무료로 제공받고 수강한 후기를 담고 있습니다.

 

 

 

 

References

 

 

다음 글 보기

 

[인프런 리프 2기] 4주 - 파이썬 병행성

이전 글 보기 [인프런 리프 2기] 3주 - 파이썬 일급 함수 오늘은 인프런 리프 3주차 미션인 섹션 5를 정리해 보겠습니다. 제가 수강하는 강의는 [우리를 위한 프로그래밍], 파이썬 중급 과정 인프

dev-dain.tistory.com

 

728x90
반응형
0 Comments
댓글쓰기 폼