오늘은 함수형 사고를 보는 대표적인 면접 문제를 정리해보겠습니다. 처음 보면 낯설지만, 핵심 원리만 이해하면 클로저함수를 값처럼 다루는 방식이 왜 중요한지 한 번에 감이 옵니다.


문제

cons(a, b)는 두 값을 하나의 쌍처럼 묶어 주는 함수입니다. 그리고 다음 두 함수가 있다고 가정합니다.

car(pair) — 쌍의 첫 번째 값을 반환

cdr(pair) — 쌍의 두 번째 값을 반환

예를 들면 다음과 같습니다.

car(cons(3, 4))  # 3
cdr(cons(3, 4))  # 4

다음과 같이 cons가 구현되어 있을 때, car와 cdr를 구현하는 것이 문제입니다.

def cons(a, b):
    def pair(f):
        return f(a, b)
    return pair

처음 떠오르는 생각

보통 pair라고 하면 (a, b) 같은 자료구조를 먼저 떠올리게 됩니다. 그런데 여기서는 값을 직접 저장해 두었다가 꺼내는 방식이 아닙니다.

핵심

cons(a, b)는 실제로 pair 데이터를 반환하는 것이 아니라, 나중에 어떤 함수를 받으면 그 함수에 (a, b)를 넣어 실행해 주는 함수를 반환합니다.

즉, cons(3, 4)의 결과는 이런 느낌입니다.

"함수 하나를 주면, 내가 그 함수에 3과 4를 넣어서 실행해줄게"


핵심 아이디어

car는 pair에게 첫 번째 값만 반환하는 함수를 넘기면 됩니다.

def first(a, b):
    return a

cdr는 pair에게 두 번째 값만 반환하는 함수를 넘기면 됩니다.

def second(a, b):
    return b

코드

def cons(a, b):
    def pair(f):
        return f(a, b)
    return pair

def car(pair):
    def first(a, b):
        return a
    return pair(first)

def cdr(pair):
    def second(a, b):
        return b
    return pair(second)

더 짧게 쓰는 방법

def cons(a, b):
    return lambda f: f(a, b)

def car(pair):
    return pair(lambda a, b: a)

def cdr(pair):
    return pair(lambda a, b: b)

동작 방식

코드만 보면 짧아서 오히려 더 추상적으로 느껴질 수 있습니다. 그래서 cons(3, 4)가 만들어진 뒤 어떤 일이 일어나는지 한 번 천천히 따라가 보겠습니다.

p = cons(3, 4)

이 시점의 p(3, 4) 같은 튜플이 아닙니다.

정확히는 아래와 비슷한 함수를 하나 받은 상태입니다.

def pair(f):
    return f(3, 4)

p는 이미 34를 기억하고 있고, 나중에 함수 하나를 받으면 그 함수에 34를 넣어 실행합니다.

이제 car(p)를 호출한다고 해보겠습니다.

def car(pair):
    def first(a, b):
        return a
    return pair(first)

여기서 pair(first)는 결국 아래와 같습니다.

first(3, 4)

그리고 first(3, 4)3을 반환하므로, car(p)의 결과도 3이 됩니다.

cdr(p)도 완전히 같은 구조입니다.

def cdr(pair):
    def second(a, b):
        return b
    return pair(second)

이때는 second(3, 4)가 실행되고, 결과로 4가 나옵니다.

핵심은 pair가 값을 직접 꺼내는 구조가 아니라, 값 두 개를 가지고 있다가 전달받은 함수에 넘겨주는 구조라는 점입니다.


실행 예시

p = cons(3, 4)

print(car(p))  # 3
print(cdr(p))  # 4

일반적인 pair와 무엇이 다를까?

보통 pair를 떠올리면 아래처럼 튜플이나 리스트를 생각하게 됩니다.

pair = (3, 4)

이 방식에서는 값이 구조 안에 직접 저장됩니다. 첫 번째 값을 꺼내고 싶으면 pair[0], 두 번째 값을 꺼내고 싶으면 pair[1]처럼 접근합니다.

하지만 이 문제의 pair는 그런 구조가 아닙니다.

p = cons(3, 4)

p는 데이터를 담은 상자가 아니라, 요청받은 방식대로 값을 건네주는 함수입니다.

그래서 이 문제를 이해하는 핵심은 "pair를 저장 구조로 보지 말고, 동작으로 보자"는 데 있습니다.


왜 이런 방식이 가능할까?

  1. 클로저
    cons(a, b)가 끝난 뒤에도 내부 함수 pair는 바깥 변수였던 ab를 기억합니다.

  2. 함수도 값처럼 다룰 수 있다
    파이썬에서는 함수를 다른 함수의 인자로 넘길 수 있습니다. 이 문제는 그 성질을 아주 직접적으로 보여줍니다.

  3. 데이터를 함수로 표현할 수 있다
    pair를 리스트나 튜플 같은 자료구조로 저장하지 않고, 함수 자체로 표현했기 때문입니다.

일반적인 pair는 서랍 안에 값 2개를 넣어두는 방식입니다. 반면 이 문제의 pair는 값 2개를 기억하고 있다가, 요청하는 방식대로 꺼내주는 직원에 가깝습니다.


실수하기 쉬운 부분

first를 넘겨야지 first()를 실행하면 안 됩니다

아래처럼 함수 자체를 넘겨야 합니다.

return pair(first)

그런데 아래처럼 쓰면 안 됩니다.

return pair(first())

이렇게 쓰면 first가 먼저 실행되려고 하는데, ab를 받을 준비도 안 된 상태라 흐름이 완전히 깨집니다.

pair를 값 저장소처럼 생각하면 헷갈립니다

이 문제에서 pairpair[0]처럼 접근할 수 있는 자료형이 아닙니다. 함수 하나를 받아서 (a, b)에 적용하는 함수라는 관점을 놓치면 구현이 꼬이기 쉽습니다.

carcdr는 선택 규칙을 전달하는 함수입니다

car는 "첫 번째 값을 달라"는 규칙을 넘깁니다.
cdr는 "두 번째 값을 달라"는 규칙을 넘깁니다.

즉 두 함수는 직접 값을 찾는 것이 아니라, pair에게 어떤 방식으로 값을 꺼낼지 알려주는 역할을 합니다.


정리

이 문제는 코드 길이는 짧지만, 사고 방식은 꽤 깊은 편입니다. 단순히 정답만 외우기보다 왜 pair를 함수로 표현할 수 있는지를 이해하면, 클로저와 고차 함수 개념이 훨씬 더 잘 잡힙니다.

정리하면, cons(a, b)는 두 값을 저장한 자료구조를 반환하는 것이 아니라 두 값을 기억하는 함수를 반환합니다. 따라서 car와 cdr는 각각 원하는 값을 꺼내는 함수를 전달해서 구현할 수 있습니다.