람다표현식과 맵, 필터, 리듀스 (Python)

람다(lambda, \lambda)는 본래 수리논리학에서의 함수정의를 추상화한 형식 체계로, 간단히 말해서 이름이 없는 함수 혹은 인라인으로 정의하는 함수로 이해할 수 있다. 수학에서의 람다대수의 정의와 비슷하게 파이썬에서는 다음과 같이 람다함수를 정의한다.

lambda {파라미터,...} : {표현식}

람다식은 그 자체로 표현식이며 다음 구성 요소로 작성한다.

  1. 키워드 lambda
  2. 파라미터 : 컴마로 구분되는 1개 이상의 파라미터. 파라미터는 반드시 1개 이상이어어야 한다.
  3. 콜론 :
  4. 표현식 : 파라미터와 그외 값으로 이루어지는 일련의 표현식

예를 들어 어떤 파라미터 x 에 대해서 1을 증가시킨 값을 구하는 함수는 람다대수로는 \lambda x.x+1이라고 표현하며, 이는 그대로 파이썬 코드로 다음과 같이 표현한다.

lambda x: x + 1

파라미터의 수는 반드시 한 개일 필요는 없다. 두 수를 더하거나 곱하는 함수를 람다식으로 작성하고 싶다면 다음과 같이 표현할 수 있다.

# add
lambda x,y : x + y

# multiply
lambda x,y : x * y

이처럼 람다식은 파라미터와 표현식을 결합한 것이며, 그 자체로는 파라미터를 표현식에 대입하여 값으로 평가하는 객체를 뜻하므로 함수로 평가된다. 즉 람다식 = 익명함수인 셈이다. 흔히 람다식을 이해하는 방법이 바로 익명함수 혹은 인라인함수이다. 람다함수는 그 자체로도 실제 이름이 없는 함수이며, 표현식이다. 따라서 대입구문에서 우변에 사용될 수 있고, 변수에 바인딩하여 이름을 부여할 수 있다는 말이다. 그리고 이렇게 만들어지는 함수는 일반 함수와 사실상 동일하다. 따라서 람다식을 대입문에 사용하고, 변수명을 사용해서 함수호출과 똑같은 방식으로 실행할 수 있다.

add = lamdba x,y : x + y
add(3, 4) # => 7

맵/필터 그리고 리듀스

파이썬에서 람다표현식이 많이 쓰이는 곳은 바로 맵/필터/리듀스이다. 이 세가지 연산은 리스트(혹은 연속열)의 원소를 변환하거나, 특정 조건으로 필터링하거나 혹은 여러 개의 값을 차곡차곡 접어서 하나의 값으로 압축하는 작업으로, 사실상 리스트와 관련된 대부분의 연산 작업이라 할 수 있다. (동시에 함수형 패러다임에서 아주 중요한 기본 조작 개념이기도 하다.) 우선 맵과 필터의 몇 가지 예를 들어보면 다음과 같다.

파이썬 3에서 map(), filter() 의 결과는 리스트가 아니라, 각각 map, filter 객체이다. 이들은 일종의 제너레이터로 느긋하게 평가되는 연속열이다. 만약 맵, 필터를 사용하여 리스트를 얻고 싶다면, 그 결과를 다시 list()에 인자로 집어 넣어 리스트로 변환해야 한다.

xs = list(range(10))

## 각 수를 두 배한다
## 각 수를 두 배하기 위한 작업은 인라인함수로 작성되며,
## 여기서 lambda 가 사용된다.

doubled = map(lambda x: x*2, xs)
for x in doubled:
  print(x)
## 0, 2, 4, 6, 8 ... 18을 출력한다.

## 각 수에 1을 더한 후 제곱한다.
squared = map(lambda x: (x+1)**2, xs)
list(squared)
## [1, 4, 9, ... , 100]

## 짝수로 필터링한다.
evens = filter(lamdba x: x % 2 is 0, xs)
list(evens)
## [0, 2, 4, 6, 8]

하지만 실제 파이썬 코드에서 map(), filter() 함수가 직접 쓰이는 일은 그다지 많지 않다. 왜냐하면 이 작업들은 모두 리스트 축약(List Comprehension) 문법으로 대체 가능하기 때문이다. 많은 파이썬 강좌에서도 맵/필터보다는 리스트 축약을 사용할 것을 권장한다.

리스트 축약의 문법은 다음과 같다.

[ {표현식} for {변수} in {반복자/연속열} if {조건표현식} ]

리스트 축약은 람다식의 본체가 될 표현식을 그대로 사용하기 때문에 따로 람다함수를 정의할 필요가 없다. 또 if {조건표현식} 은 선택적으로 사용할 수 있으며, 이 부분이 필터에 해당한다. 즉 하나의 구문에서 맵과 필터를 동시에 쓸 수 있는 장점이 있다. 다음은 위 예제의 내용을 리스트 축약으로 대체한 표현이다.

xs = range(10)
doubled = [x*2 for x in xs]
squared = [(x+1)**2 for x in xs]
evens = [x for x in xs if x % 2 is 0]

리스트 축약은 평가되어 리스트로 만들어진다. 그런데 “리스트로 만들어진다”는 말은 조금 주의를 기울여야 한다. 위 예제에서 사용된 range()는 일종의 제너레이터로 말하자면 느긋하게 평가되는 연속열이다. 느긋하게 평가된다는 말은 필요한 시점에 필요한 원소가 하나씩 생성되고 평가된다는 뜻이다. 그런데 리스트 축약을 사용하게 되면, 리스트 축약 자체가 “리스트로 평가되는 표현식”이므로 제너레이터 내의 모든 원소가 한 번에 평가되어 리스트가 생성된다.

만약 원본이 100,000 개짜리 원소를 만들어내는 제너레이터인 경우, 리스트 축약으로 변환하는 경우, 100,000개 짜리 원소의 리스트가 생성된다는 뜻이다. 앞서 map(), filter() 의 결과가 리스트가 아닌 제너레이터라고 했는데, 제너레이터는 느긋하게 평가되는 연속열이며, 한 번에 하나의 원소가 필요한 시점에 만들어지기 때문에 메모리를 많이 쓰지 않는다. 리스트 축약은 편리한 대신 전체 연속열을 모두 평가해서 리스트로 만들기 때문에 메모리 낭비가 심하다. 같은 이유로 파이썬2에서는 range() 대신에 xrange() 를 사용하기를 권장하였으며, 파이썬3에 와서는 range(), map(), filter()는 모두 리스트가 아닌 제너레이터를 리턴하도록 변경되었다.

만약 for ... in 문에 사용할 용도로만 사용한다면 리스트 축약 대신에 메모리를 절약할 수 있는 제너레이터 축약 표현을 쓸 수 있다. 제너레이터 축약 표현은 리스트 축약 표현과 그 문법이 동일하지만, 구문 전체를 대괄호([ ... ])가 아닌 소괄호(( ... ))로 감싼다. 특히 리스트 축약 구문이 함수의 인자로 넘겨지기 때문에 외부에 이미 괄호가 있다면 괄호마저 생략할 수 있다.

xs = range(10) # xs는 제너레이터이다.
doubled = (x*2 for x in xs) ## doubled 역시 제너레이터이다.
## 제너레이터 축약을 사용해서 완전 제곱수를 순회한다.
for s in ((x+1)**2 for x in xs):
  print(s)

## str.join()은 리스트외에도 문자열의 반복자를 받을 수 있다.
line = ','.join(str(x+1) for x in xs)  ## 아예 괄호 자체를 생략했다.
## '1,2,3,4,5,6,7,8,9,10'

이처럼 map, filter 함수는 축약 문법으로 완벽하게 대체가 가능하며, 표현 또한 간결하기 때문에 파이썬에서는 축약 문법이 적극 권장된다. 축약 문법의 대표적인 장점으로 꼽히는 것은 다름 아닌 ‘가독성이 좋다’는 것인데, 이 가독성의 향상은 아이러니하게도 람다식의 제거에 기인하게 된다. 즉 위의 람다식을 쓴 표현들을 보면 알겠지만, 함수의 인자로 쓰인 람다식은 그 자체에 ,를 포함하는 등의 이유로 다른 파라미터와의 시각적인 구분히 모호해지고, 읽기가 어려워진다.

읽기가 어려워지는 것은 파이썬에서는 일종의 죄악이다. 다행히 맵과 필터는 람다식을 비켜갈 수 있는 문법이 내장되어 있었으나, 람다식을 쓰지 않을 수 없는 작업이 있었으니, 바로 리듀스(reduce)이다. 리듀스는 말 그대로 ‘리스트나 연속열을 줄여나가서 하나의 값으로 축약한다’는 의미이다.

리듀스

우리는 흔히 리스트를 일종의 배열 대신으로 생각한다. 즉 리스트는 어떤 원소들의 집합으로 기능한다고 생각했다. 하지만 또 다른 관점으로보면 리스트는 값의 상태가 하나 이상인 값으로 생각할 수 있다. 리듀스는 이런 여러 개의 값인 상태를 축약하여 하나의 값으로 만드는 과정이며, 그 과정이 마치 리스트를 앞에서부터 차곡차곡 접어나가는 것처럼 보인다고 해서 폴딩(folding)이라고 한다.

리듀스의 동작원리는 다음과 같다.

  1.  단계를 거듭할수록 값의 상태의 개수를 점점 줄여나가야 하므로, 여기에 사용되는 연산(함수)은 2인자함수여야 한다. 즉 값 두 개를 받아서 하나를 리턴한다.
  2. 그리고 연산을 계속해나가면서 중간값을 누적 시켜 나갈 변수가 필요하다. 이 변수는 최초의 초기값을 필요로 한다.
  3. 중간값과 리스트의 맨 앞에 있는 값. 이렇게 두 값을 1의 함수에 넣어 평가한다. 그 결과로 중간값을 업데이트한다.
  4. 다시 중간값과 그 다음 리스트 원소값을 함수에 넣어 평가하고 그 결과로 중간값을 업데이트한다.
  5. 리스트의 끝에 다다를 때까지 4의 과정을 반복한다.
  6. 리스트에 더 이상의 원소가 없다면 중간값이 최종값이 된다.

이를 통해서 [1, 2, 3, 4]+ 를 사용하여 축약하는 과정을 살펴보면 아래와 같이 추적된다.

중간값         리스트             평가식         
  0           [1, 2, 3, 4]  ->  0 + 1   #1
  1           [2, 3, 4]     ->  1 + 2   #2
  3           [3, 4]        ->  3 + 3
  6           [4]           ->  6 + 4
  10          []            ::  완료
  1.  최초 중간값은 0이며, 리스트의 맨 첫 값은 1 이다. 이 둘을 더한다. 그 결과는 1이며 중간값은 1이 된다.
  2. 중간값은 1이 되었다. 다시 중간값과 리스트의 첫 값을 더한다.
  3. 이 과정을 반복하여 리스트가 빌 때까지 진행한다.
  4. 완료된 후의 중간값은 10이며 이 값은 1 + 2 + 3 + 4 이다. 즉 리스트의 합계이다.

기술적으로 reduce 함수를 작성하는 것은 사실 매우 간단하다.

def reduce(fn, xs, init):
  acc = init
  for x in xs:
    acc = fn(acc, x)
  return acc

경우에 따라서는 초기값 없이, 리스트의 첫 원소를 초기값으로 써서 축약하고 싶을 때가 있을 것이다. 그렇다면 다음과 같이 변경할 수도 있다.

def reduce(fn, xs, init=None):
  if init is None:
    return reduce(fn, xs[1:], x[0])
  acc = init
  for x in xs:
    acc = fn(acc, x)
  return acc

앞에서 설명한 리스트의 합계를 구하는 과정을 reduce() 함수를 써서 구현할 수 있을까?

리듀스를 사용한 리스트 조작 구현

sum()에 해당하는 부분이다.  리듀스를 써서 리스트 원소들의 전체 합계를 구할 수 있다.

xs = range(10)
total = reduce(lambda x,y: x+y, xs)

덧셈이 아닌 곱셈을 이용하면 리스트 원소의 누적곱을 구할 수 있다.

product = reduce(lambda x,y: x*y, xs)
## 그렇다면 팩토리얼은 다음과 같이 구할 수 있다. 
## n! = [1, 2, 3, ..., n] |> product
factial = lambda n: product(range(1, n+1))

비슷한 아이디어로 리듀스를 이용해서 연속열의 길이를 구할 수 있다.

len_xs = reduce(lambda x, y: x + 1, xs, 0)

여기서는 람다식 본체에서는 파라미터로 받은 y 값을 사용하지 않고 무시한다. 따라서 0부터 시작해서 원소 수만큼 1씩 더해나가니 그 결과는 리스트의 길이가 될 것이다.

참고로 내장함수 중의 len() 함수는 다른 sum() 등의 함수와 달리 제너레이터에 대해서는 길이를 구하지 않는다. 제너레이터의 크기를 구하고 싶다면 이 아이디어를 이용해서 다음과 같이 사용한다. l = sum(1 for _ in xs) 이는 연속열의 각 원소를 1로 맵핑한 다음 합을 구하여 크기를 계산하는 팁이다.

최대, 최소

최대값, 최소값은 어떤가? 최대값을 구한다고 치면, 첫 원소를 중간값으로 두고, 다음 원소가 이보다 크면 중간값을 다음 원소값으로 교체한다. 이런 식으로 최대값이 중간값이 되도록 하여 리스트의 끝에 다다르면 된다. 즉 (중간값, 다음값)에 대해 둘 중 작은 값을 버리고 큰 값만을 취하는 함수로 리스트를 접으면 된다. 이 표현은 A if CONDITION else B 라는 조건 표현식을 사용한다.

min_xs = reduce(lambda x,y: x if x < y else y, xs)
max_xs = reduce(lamdba x,y: x if x > y else y, xs)

맵 / 필터

이상하게 들리겠지만, 맵과 필터 역시 리듀스로 구현할 수 있다. 접는 함수가 실제로 값을 접지 않고 리스트를 만들어 누적해나가면 된다. 결과값이 단일 값이 아닌 다시 리스트이므로, 초기값을 리스트로 주고 맵핑한 값을 중간값에 계속 더해나간다.

def my_map(fn, xs):
  return reduce(lambda x,y: x + [fn(y)], xs, [])

def my_filter(fn, xs):
  return reduce(lambda x,y: (x + [y]) if fn(y) else x, xs)

## 이 둘을 결합한
def my_map_filter(fn1, xs, fn2):
  return reduce(lambda x,y: (x + [fn(y)]) if fn2(y) else x, xs)

정리

이처럼 리듀스와 람다표현식을 사용하면 리스트나 그외 파이썬의 연속열 객체들을 접어서 하나의 값으로 축약하는 작업을 for 문을 사용하지 않고 간단하게 표현할 수 있다. 비록 람다함수가 가독성면에서 불리하게 작용하기 때문에 널리 권장되는 것 같지는 않지만, 이 개념을 잘 이해하고 있다면 간결한 코드로 표현력을 극대화할 수 있고, 데이터 조작 관점에서의 리스트를 함수형 패러다임에서의 그것과 같이 바라볼 수 있도록 시각을 확장할 수 있다.